Showing 1 changed files with 274 additions and 41 deletions
+274 -41
USB Meter/Views/Meter/MeterView.swift
@@ -11,18 +11,43 @@ import SwiftUI
11 11
 import CoreBluetooth
12 12
 
13 13
 struct MeterView: View {
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
+    }
14 35
     
15 36
     @EnvironmentObject private var meter: Meter
16 37
     
17 38
     @State var dataGroupsViewVisibility: Bool = false
18 39
     @State var recordingViewVisibility: Bool = false
19 40
     @State var measurementsViewVisibility: Bool = false
41
+    @State private var selectedMeterTab: MeterTab = .connection
20 42
     private var myBounds: CGRect { UIScreen.main.bounds }
21 43
     private let actionStripPadding: CGFloat = 10
22 44
     private let actionDividerWidth: CGFloat = 1
23 45
     private let actionButtonMaxWidth: CGFloat = 156
24 46
     private let actionButtonMinWidth: CGFloat = 88
25 47
     private let actionButtonHeight: CGFloat = 108
48
+    private let pageHorizontalPadding: CGFloat = 12
49
+    private let pageVerticalPadding: CGFloat = 12
50
+    private let contentCardPadding: CGFloat = 16
26 51
 
27 52
     var body: some View {
28 53
         GeometryReader { proxy in
@@ -32,14 +57,16 @@ struct MeterView: View {
32 57
                 if landscape {
33 58
                     landscapeDeck(size: proxy.size)
34 59
                 } else {
35
-                    portraitContent
60
+                    portraitContent(size: proxy.size)
36 61
                 }
37 62
             }
38 63
             .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
64
+            #if !targetEnvironment(macCatalyst)
39 65
             .navigationBarHidden(landscape)
66
+            #endif
40 67
         }
41 68
         .background(meterBackground)
42
-        .navigationBarTitle("Meter")
69
+        .navigationBarTitle(meter.name.isEmpty ? "Meter" : meter.name)
43 70
         .navigationBarItems(trailing: HStack (spacing: 6) {
44 71
             if meter.operationalState > .notPresent {
45 72
                 RSSIView(RSSI: meter.btSerial.RSSI)
@@ -58,64 +85,270 @@ struct MeterView: View {
58 85
         })
59 86
     }
60 87
 
61
-    private var portraitContent: some View {
62
-        ScrollView {
63
-            VStack(alignment: .leading, spacing: 16) {
64
-                connectionCard(showsActions: meter.operationalState == .dataIsAvailable)
88
+    private func portraitContent(size: CGSize) -> some View {
89
+        portraitSegmentedDeck(size: size)
90
+    }
65 91
 
66
-                if meter.operationalState == .dataIsAvailable {
67
-                    LiveView()
68
-                        .padding(18)
69
-                        .meterCard(tint: meter.color, fillOpacity: 0.12, strokeOpacity: 0.20)
70
-
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)
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
+                        )
77 162
                     }
163
+                    .buttonStyle(.plain)
164
+                    .accessibilityLabel(tab.title)
78 165
                 }
79 166
             }
80
-            .padding()
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)
81 189
         }
82 190
     }
83 191
 
84
-    private func landscapeDeck(size: CGSize) -> some View {
85
-        TabView {
86
-            landscapeFace {
87
-                connectionCard(compact: true, showsActions: meter.operationalState == .dataIsAvailable)
88
-                    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
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
89 202
             }
203
+        case .chart:
204
+            if meter.measurements.power.context.isValid && meter.operationalState == .dataIsAvailable {
205
+                landscapeChartPage(size: size)
206
+            } else {
207
+                landscapeConnectionPage
208
+            }
209
+        }
210
+    }
90 211
 
212
+    @ViewBuilder
213
+    private func portraitSegmentedContent(size: CGSize) -> some View {
214
+        switch selectedMeterTab {
215
+        case .connection:
216
+            portraitConnectionPage(size: size)
217
+        case .live:
91 218
             if meter.operationalState == .dataIsAvailable {
92
-                landscapeFace {
93
-                    LiveView(compactLayout: true, availableSize: size)
94
-                        .padding(16)
95
-                        .frame(maxWidth: .infinity, alignment: .topLeading)
96
-                        .meterCard(tint: meter.color, fillOpacity: 0.12, strokeOpacity: 0.20)
97
-                }
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
+    }
98 231
 
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
-                    }
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)
108 281
                 }
109 282
             }
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)
110 344
         }
111
-        .tabViewStyle(PageTabViewStyle(indexDisplayMode: .automatic))
112 345
     }
113 346
 
114 347
     private func landscapeFace<Content: View>(@ViewBuilder content: () -> Content) -> some View {
115 348
         content()
116 349
             .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
117
-        .padding(.horizontal, 12)
118
-        .padding(.vertical, 12)
350
+        .padding(.horizontal, pageHorizontalPadding)
351
+        .padding(.vertical, pageVerticalPadding)
119 352
         .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
120 353
     }
121 354