Showing 38 changed files with 1940 additions and 841 deletions
+4 -4
USB Meter/Model/BluetoothManager.swift
@@ -62,7 +62,7 @@ class BluetoothManager : NSObject, ObservableObject {
62 62
         
63 63
         let macAddress = MACAddress(from: manufacturerData.suffix(from: 2))
64 64
         let macAddressString = macAddress.description
65
-        appData.registerKnownMeter(macAddress: macAddressString, modelName: model.canonicalName, advertisedName: peripheralName)
65
+        appData.registerMeter(macAddress: macAddressString, modelName: model.canonicalName, advertisedName: peripheralName)
66 66
         appData.noteMeterSeen(at: Date(), macAddress: macAddressString)
67 67
         
68 68
         if appData.meters[peripheral.identifier] == nil {
@@ -159,7 +159,7 @@ extension BluetoothManager : CBCentralManagerDelegate {
159 159
             usbMeter.btSerial.connectionEstablished()
160 160
         }
161 161
         else {
162
-            track("Connected to unknown meter with UUID: '\(peripheral.identifier)'")
162
+            track("Connected to meter with UUID: '\(peripheral.identifier)'")
163 163
         }
164 164
     }
165 165
     
@@ -170,7 +170,7 @@ extension BluetoothManager : CBCentralManagerDelegate {
170 170
             usbMeter.btSerial.connectionClosed()
171 171
         }
172 172
         else {
173
-            track("Disconnected from unknown meter with UUID: '\(peripheral.identifier)'")
173
+            track("Disconnected from meter with UUID: '\(peripheral.identifier)'")
174 174
         }
175 175
     }
176 176
 
@@ -179,7 +179,7 @@ extension BluetoothManager : CBCentralManagerDelegate {
179 179
         if let usbMeter = appData.meters[peripheral.identifier] {
180 180
             usbMeter.btSerial.connectionClosed()
181 181
         } else {
182
-            track("Failed to connect to unknown meter with UUID: '\(peripheral.identifier)'")
182
+            track("Failed to connect to meter with UUID: '\(peripheral.identifier)'")
183 183
         }
184 184
     }
185 185
 }
+12 -12
USB Meter/Model/MeterNameStore.swift
@@ -57,7 +57,7 @@ final class MeterNameStore {
57 57
     static let shared = MeterNameStore()
58 58
 
59 59
     private enum Keys {
60
-        static let knownMeters = "MeterNameStore.knownMeters"
60
+        static let meters = "MeterNameStore.meters"
61 61
         static let localMeterNames = "MeterNameStore.localMeterNames"
62 62
         static let localTemperatureUnits = "MeterNameStore.localTemperatureUnits"
63 63
         static let localModelNames = "MeterNameStore.localModelNames"
@@ -128,15 +128,15 @@ final class MeterNameStore {
128 128
         return dateDictionary(for: Keys.localLastConnected)[normalizedMAC]
129 129
     }
130 130
 
131
-    func registerKnownMeter(macAddress: String, modelName: String?, advertisedName: String?) {
131
+    func registerMeter(macAddress: String, modelName: String?, advertisedName: String?) {
132 132
         let normalizedMAC = normalizedMACAddress(macAddress)
133 133
         guard !normalizedMAC.isEmpty else {
134
-            track("MeterNameStore ignored known meter registration with invalid MAC '\(macAddress)'")
134
+            track("MeterNameStore ignored meter registration with invalid MAC '\(macAddress)'")
135 135
             return
136 136
         }
137 137
 
138 138
         var didChange = false
139
-        didChange = updateKnownMeters(normalizedMAC) || didChange
139
+        didChange = updateMetersSet(normalizedMAC) || didChange
140 140
         didChange = updateDictionaryValue(
141 141
             for: normalizedMAC,
142 142
             value: normalizedName(modelName),
@@ -171,7 +171,7 @@ final class MeterNameStore {
171 171
         }
172 172
 
173 173
         var didChange = false
174
-        didChange = updateKnownMeters(normalizedMAC) || didChange
174
+        didChange = updateMetersSet(normalizedMAC) || didChange
175 175
 
176 176
         if let name {
177 177
             didChange = updateDictionaryValue(
@@ -203,7 +203,7 @@ final class MeterNameStore {
203 203
         let advertisedNames = dictionary(for: Keys.localAdvertisedNames, store: defaults)
204 204
         let lastSeenValues = dateDictionary(for: Keys.localLastSeen)
205 205
         let lastConnectedValues = dateDictionary(for: Keys.localLastConnected)
206
-        let macAddresses = knownMeters()
206
+        let macAddresses = meters()
207 207
             .union(names.keys)
208 208
             .union(temperatureUnits.keys)
209 209
             .union(modelNames.keys)
@@ -253,8 +253,8 @@ final class MeterNameStore {
253 253
         return rawValues.mapValues(Date.init(timeIntervalSince1970:))
254 254
     }
255 255
 
256
-    private func knownMeters() -> Set<String> {
257
-        Set((defaults.array(forKey: Keys.knownMeters) as? [String]) ?? [])
256
+    private func meters() -> Set<String> {
257
+        Set((defaults.array(forKey: Keys.meters) as? [String]) ?? [])
258 258
     }
259 259
 
260 260
     private func mergedDictionary(localKey: String, cloudKey: String) -> [String: String] {
@@ -266,12 +266,12 @@ final class MeterNameStore {
266 266
     }
267 267
 
268 268
     @discardableResult
269
-    private func updateKnownMeters(_ macAddress: String) -> Bool {
270
-        var known = knownMeters()
269
+    private func updateMetersSet(_ macAddress: String) -> Bool {
270
+        var known = meters()
271 271
         let initialCount = known.count
272 272
         known.insert(macAddress)
273 273
         guard known.count != initialCount else { return false }
274
-        defaults.set(Array(known).sorted(), forKey: Keys.knownMeters)
274
+        defaults.set(Array(known).sorted(), forKey: Keys.meters)
275 275
         return true
276 276
     }
277 277
 
@@ -329,7 +329,7 @@ final class MeterNameStore {
329 329
         guard values[normalizedMAC] != timeInterval else { return }
330 330
         values[normalizedMAC] = timeInterval
331 331
         defaults.set(values, forKey: key)
332
-        _ = updateKnownMeters(normalizedMAC)
332
+        _ = updateMetersSet(normalizedMAC)
333 333
         notifyChange()
334 334
     }
335 335
 
+0 -32
USB Meter/Views/BorderView.swift
@@ -1,32 +0,0 @@
1
-//
2
-//  BorderView.swift
3
-//  USB Meter
4
-//
5
-//  Created by Bogdan Timofte on 11/04/2020.
6
-//  Copyright © 2020 Bogdan Timofte. All rights reserved.
7
-//
8
-
9
-import SwiftUI
10
-
11
-struct BorderView: View {
12
-    let show: Bool
13
-    var fillColor: Color = .clear
14
-    var opacity = 0.5
15
-    
16
-    var body: some View {
17
-        ZStack {
18
-        RoundedRectangle(cornerRadius: 10)
19
-            .foregroundColor(fillColor).opacity(opacity)
20
-
21
-        RoundedRectangle(cornerRadius: 10)
22
-            .stroke(lineWidth: 3.0).foregroundColor(show ? fillColor : Color.clear)
23
-            .animation(.linear(duration: 0.1), value: show)
24
-        }
25
-    }
26
-}
27
-
28
-struct BorderView_Previews: PreviewProvider {
29
-    static var previews: some View {
30
-        BorderView(show: true)
31
-    }
32
-}
+1 -1
USB Meter/Views/Meter/ChevronView.swift → USB Meter/Views/Components/Generic/ChevronView.swift
@@ -1,5 +1,5 @@
1 1
 //
2
-//  SwiftUIView.swift
2
+//  ChevronView.swift
3 3
 //  USB Meter
4 4
 //
5 5
 //  Created by Bogdan Timofte on 02/05/2020.
+2 -2
USB Meter/Views/Meter/RSSIView.swift → USB Meter/Views/Components/Generic/RSSIView.swift
@@ -1,5 +1,5 @@
1 1
 //
2
-//  SwiftUIView.swift
2
+//  RSSIView.swift
3 3
 //  USB Meter
4 4
 //
5 5
 //  Created by Bogdan Timofte on 14/03/2020.
@@ -54,7 +54,7 @@ struct RSSIView: View {
54 54
 }
55 55
 
56 56
 
57
-struct SwiftUIView_Previews: PreviewProvider {
57
+struct RSSIView_Previews: PreviewProvider {
58 58
     static var previews: some View {
59 59
         RSSIView(RSSI: -80).frame(width: 20, height: 20, alignment: .center)
60 60
     }
+18 -634
USB Meter/Views/Meter/MeterView.swift
@@ -41,25 +41,10 @@ struct MeterView: View {
41 41
 
42 42
     private static let isMacIPadApp: Bool = ProcessInfo.processInfo.isiOSAppOnMac
43 43
     
44
-    @State var dataGroupsViewVisibility: Bool = false
45
-    @State var recordingViewVisibility: Bool = false
46
-    @State var measurementsViewVisibility: Bool = false
47 44
     @State private var selectedMeterTab: MeterTab = .connection
48 45
     @State private var navBarTitle: String = "Meter"
49 46
     @State private var navBarShowRSSI: Bool = false
50 47
     @State private var navBarRSSI: Int = 0
51
-    @State private var editingName = false
52
-    @State private var editingScreenTimeout = false
53
-    @State private var editingScreenBrightness = false
54
-    private var myBounds: CGRect { UIScreen.main.bounds }
55
-    private let actionStripPadding: CGFloat = 10
56
-    private let actionDividerWidth: CGFloat = 1
57
-    private let actionButtonMaxWidth: CGFloat = 156
58
-    private let actionButtonMinWidth: CGFloat = 88
59
-    private let actionButtonHeight: CGFloat = 108
60
-    private let pageHorizontalPadding: CGFloat = 12
61
-    private let pageVerticalPadding: CGFloat = 12
62
-    private let contentCardPadding: CGFloat = 16
63 48
 
64 49
     var body: some View {
65 50
         GeometryReader { proxy in
@@ -258,21 +243,25 @@ struct MeterView: View {
258 243
     private func landscapeSegmentedContent(size: CGSize) -> some View {
259 244
         switch selectedMeterTab {
260 245
         case .connection:
261
-            landscapeConnectionPage
246
+            MeterConnectionTabView(size: size, isLandscape: true)
262 247
         case .live:
263 248
             if meter.operationalState == .dataIsAvailable {
264
-                landscapeLivePage(size: size)
249
+                MeterLiveTabView(size: size, isLandscape: true)
265 250
             } else {
266
-                landscapeConnectionPage
251
+                MeterConnectionTabView(size: size, isLandscape: true)
267 252
             }
268 253
         case .chart:
269 254
             if meter.measurements.power.context.isValid && meter.operationalState == .dataIsAvailable {
270
-                landscapeChartPage(size: size)
255
+                MeterChartTabView(size: size, isLandscape: true)
271 256
             } else {
272
-                landscapeConnectionPage
257
+                MeterConnectionTabView(size: size, isLandscape: true)
273 258
             }
274 259
         case .settings:
275
-            landscapeSettingsPage(size: size)
260
+            MeterSettingsTabView(isMacIPadApp: Self.isMacIPadApp) {
261
+                withAnimation(.easeInOut(duration: 0.22)) {
262
+                    selectedMeterTab = .connection
263
+                }
264
+            }
276 265
         }
277 266
     }
278 267
 
@@ -280,310 +269,28 @@ struct MeterView: View {
280 269
     private func portraitSegmentedContent(size: CGSize) -> some View {
281 270
         switch selectedMeterTab {
282 271
         case .connection:
283
-            portraitConnectionPage(size: size)
272
+            MeterConnectionTabView(size: size, isLandscape: false)
284 273
         case .live:
285 274
             if meter.operationalState == .dataIsAvailable {
286
-                portraitLivePage(size: size)
275
+                MeterLiveTabView(size: size, isLandscape: false)
287 276
             } else {
288
-                portraitConnectionPage(size: size)
277
+                MeterConnectionTabView(size: size, isLandscape: false)
289 278
             }
290 279
         case .chart:
291 280
             if meter.measurements.power.context.isValid && meter.operationalState == .dataIsAvailable {
292
-                portraitChartPage
281
+                MeterChartTabView(size: size, isLandscape: false)
293 282
             } else {
294
-                portraitConnectionPage(size: size)
283
+                MeterConnectionTabView(size: size, isLandscape: false)
295 284
             }
296 285
         case .settings:
297
-            portraitSettingsPage(size: size)
298
-        }
299
-    }
300
-
301
-    private func portraitConnectionPage(size: CGSize) -> some View {
302
-        portraitFace {
303
-            VStack(alignment: .leading, spacing: 12) {
304
-                connectionCard(
305
-                    compact: prefersCompactPortraitConnection(for: size),
306
-                    showsActions: meter.operationalState == .dataIsAvailable
307
-                )
308
-
309
-                homeInfoPreview
310
-            }
311
-        }
312
-    }
313
-
314
-    private func portraitLivePage(size: CGSize) -> some View {
315
-        portraitFace {
316
-            LiveView(compactLayout: prefersCompactPortraitConnection(for: size), availableSize: size)
317
-                .padding(contentCardPadding)
318
-                .meterCard(tint: meter.color, fillOpacity: 0.12, strokeOpacity: 0.20)
319
-        }
320
-    }
321
-
322
-    private var portraitChartPage: some View {
323
-        portraitFace {
324
-            MeasurementChartView()
325
-                .environmentObject(meter.measurements)
326
-                .frame(minHeight: myBounds.height / 3.4)
327
-                .padding(contentCardPadding)
328
-                .meterCard(tint: meter.color, fillOpacity: 0.12, strokeOpacity: 0.20)
329
-        }
330
-    }
331
-
332
-    private var landscapeConnectionPage: some View {
333
-        landscapeFace {
334
-            VStack(alignment: .leading, spacing: 12) {
335
-                connectionCard(compact: true, showsActions: meter.operationalState == .dataIsAvailable)
336
-
337
-                homeInfoPreview
338
-            }
339
-            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
340
-        }
341
-    }
342
-
343
-    private var homeInfoPreview: some View {
344
-        VStack(spacing: 14) {
345
-            MeterInfoCard(title: "Overview", tint: meter.color) {
346
-                MeterInfoRow(label: "Name", value: meter.name)
347
-                MeterInfoRow(label: "Device Model", value: meter.deviceModelName)
348
-                MeterInfoRow(label: "Advertised Model", value: meter.modelString)
349
-                MeterInfoRow(label: "Working Voltage", value: meter.documentedWorkingVoltage)
350
-                MeterInfoRow(label: "Temperature Unit", value: meter.temperatureUnitDescription)
351
-                MeterInfoRow(label: "Last Seen", value: meterHistoryText(for: meter.lastSeen))
352
-                MeterInfoRow(label: "Last Connected", value: meterHistoryText(for: meter.lastConnectedAt))
353
-            }
354
-
355
-            MeterInfoCard(title: "Identifiers", tint: .blue) {
356
-                MeterInfoRow(label: "MAC", value: meter.btSerial.macAddress.description)
357
-                if meter.modelNumber != 0 {
358
-                    MeterInfoRow(label: "Model Identifier", value: "\(meter.modelNumber)")
359
-                }
360
-            }
361
-
362
-            MeterInfoCard(title: "Screen Reporting", tint: .orange) {
363
-                if meter.reportsCurrentScreenIndex {
364
-                    MeterInfoRow(label: "Current Screen", value: meter.currentScreenDescription)
365
-                    Text("The active screen index is reported by the meter and mapped by the app to a known label.")
366
-                        .font(.footnote)
367
-                        .foregroundColor(.secondary)
368
-                } else {
369
-                    MeterInfoRow(label: "Current Screen", value: "Not Reported")
370
-                    Text("The current screen is not reported by the device payload, or we have not yet identified where and how the protocol announces it.")
371
-                        .font(.footnote)
372
-                        .foregroundColor(.secondary)
286
+            MeterSettingsTabView(isMacIPadApp: Self.isMacIPadApp) {
287
+                withAnimation(.easeInOut(duration: 0.22)) {
288
+                    selectedMeterTab = .connection
373 289
                 }
374 290
             }
375
-
376
-            MeterInfoCard(title: "Live Device Details", tint: .indigo) {
377
-                if meter.operationalState == .dataIsAvailable {
378
-                    if !meter.firmwareVersion.isEmpty {
379
-                        MeterInfoRow(label: "Firmware", value: meter.firmwareVersion)
380
-                    }
381
-                    if meter.supportsChargerDetection {
382
-                        MeterInfoRow(label: "Detected Charger", value: meter.chargerTypeDescription)
383
-                    }
384
-                    if meter.serialNumber != 0 {
385
-                        MeterInfoRow(label: "Serial", value: "\(meter.serialNumber)")
386
-                    }
387
-                    if meter.bootCount != 0 {
388
-                        MeterInfoRow(label: "Boot Count", value: "\(meter.bootCount)")
389
-                    }
390
-                } else {
391
-                    Text("Connect to the meter to load firmware, serial, and boot details.")
392
-                        .font(.footnote)
393
-                        .foregroundColor(.secondary)
394
-                }
395
-            }
396
-        }
397
-        .padding(.horizontal, pageHorizontalPadding)
398
-    }
399
-
400
-    private func landscapeLivePage(size: CGSize) -> some View {
401
-        landscapeFace {
402
-            LiveView(compactLayout: true, availableSize: size)
403
-                .padding(contentCardPadding)
404
-                .frame(maxWidth: .infinity, alignment: .topLeading)
405
-                .meterCard(tint: meter.color, fillOpacity: 0.12, strokeOpacity: 0.20)
406 291
         }
407 292
     }
408 293
 
409
-    private func landscapeChartPage(size: CGSize) -> some View {
410
-        landscapeFace {
411
-            MeasurementChartView()
412
-                .environmentObject(meter.measurements)
413
-                .frame(height: max(250, size.height - 44))
414
-                .padding(contentCardPadding)
415
-                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
416
-                .meterCard(tint: meter.color, fillOpacity: 0.12, strokeOpacity: 0.20)
417
-        }
418
-    }
419
-
420
-    private func meterHistoryText(for date: Date?) -> String {
421
-        guard let date else {
422
-            return "Never"
423
-        }
424
-        return date.format(as: "yyyy-MM-dd HH:mm")
425
-    }
426
-
427
-    @ViewBuilder
428
-    private func portraitSettingsPage(size: CGSize) -> some View {
429
-        settingsTabContent
430
-    }
431
-
432
-    @ViewBuilder
433
-    private func landscapeSettingsPage(size: CGSize) -> some View {
434
-        settingsTabContent
435
-    }
436
-
437
-    private var settingsTabContent: some View {
438
-        VStack(spacing: 0) {
439
-            if Self.isMacIPadApp {
440
-                settingsMacHeader
441
-            }
442
-            ScrollView {
443
-                VStack(spacing: 14) {
444
-                    settingsCard(title: "Name", tint: meter.color) {
445
-                        HStack {
446
-                            Spacer()
447
-                            if !editingName {
448
-                                Text(meter.name)
449
-                                    .foregroundColor(.secondary)
450
-                            }
451
-                            ChevronView(rotate: $editingName)
452
-                        }
453
-                        if editingName {
454
-                            EditNameView(editingName: $editingName, newName: meter.name)
455
-                        }
456
-                    }
457
-
458
-                    if meter.operationalState == .dataIsAvailable && meter.supportsManualTemperatureUnitSelection {
459
-                        settingsCard(title: "Meter Temperature Unit", tint: .orange) {
460
-                            Text("TC66 temperature is shown as degrees without assuming Celsius or Fahrenheit. Keep this matched to the unit configured on the device so you can interpret the reading correctly.")
461
-                                .font(.footnote)
462
-                                .foregroundColor(.secondary)
463
-                            Picker("", selection: $meter.tc66TemperatureUnitPreference) {
464
-                                ForEach(TemperatureUnitPreference.allCases) { unit in
465
-                                    Text(unit.title).tag(unit)
466
-                                }
467
-                            }
468
-                            .pickerStyle(SegmentedPickerStyle())
469
-                        }
470
-                    }
471
-
472
-                    if meter.operationalState == .dataIsAvailable {
473
-                        settingsCard(
474
-                            title: meter.reportsCurrentScreenIndex ? "Screen Controls" : "Page Controls",
475
-                            tint: .indigo
476
-                        ) {
477
-                            if meter.reportsCurrentScreenIndex {
478
-                                Text("Use these controls when you want to change the screen shown on the device without crowding the main meter view.")
479
-                                    .font(.footnote)
480
-                                    .foregroundColor(.secondary)
481
-                            } else {
482
-                                Text("Use these controls when you want to switch device pages without crowding the main meter view.")
483
-                                    .font(.footnote)
484
-                                    .foregroundColor(.secondary)
485
-                            }
486
-
487
-                            ControlView(showsHeader: false)
488
-                        }
489
-                    }
490
-
491
-                    if meter.operationalState == .dataIsAvailable && meter.supportsUMSettings {
492
-                        settingsCard(title: "Screen Timeout", tint: .purple) {
493
-                            HStack {
494
-                                Spacer()
495
-                                if !editingScreenTimeout {
496
-                                    Text(meter.screenTimeout > 0 ? "\(meter.screenTimeout) Minutes" : "Off")
497
-                                        .foregroundColor(.secondary)
498
-                                }
499
-                                ChevronView(rotate: $editingScreenTimeout)
500
-                            }
501
-                            if editingScreenTimeout {
502
-                                EditScreenTimeoutView()
503
-                            }
504
-                        }
505
-
506
-                        settingsCard(title: "Screen Brightness", tint: .yellow) {
507
-                            HStack {
508
-                                Spacer()
509
-                                if !editingScreenBrightness {
510
-                                    Text("\(meter.screenBrightness)")
511
-                                        .foregroundColor(.secondary)
512
-                                }
513
-                                ChevronView(rotate: $editingScreenBrightness)
514
-                            }
515
-                            if editingScreenBrightness {
516
-                                EditScreenBrightnessView()
517
-                            }
518
-                        }
519
-                    }
520
-                }
521
-                .padding()
522
-            }
523
-            .background(
524
-                LinearGradient(
525
-                    colors: [meter.color.opacity(0.14), Color.clear],
526
-                    startPoint: .topLeading,
527
-                    endPoint: .bottomTrailing
528
-                )
529
-                .ignoresSafeArea()
530
-            )
531
-        }
532
-    }
533
-
534
-    private var settingsMacHeader: some View {
535
-        HStack(spacing: 12) {
536
-            Button {
537
-                selectedMeterTab = .connection
538
-            } label: {
539
-                HStack(spacing: 4) {
540
-                    Image(systemName: "chevron.left")
541
-                        .font(.body.weight(.semibold))
542
-                    Text("Back")
543
-                }
544
-                .foregroundColor(.accentColor)
545
-            }
546
-            .buttonStyle(.plain)
547
-
548
-            Text("Meter Settings")
549
-                .font(.headline)
550
-                .lineLimit(1)
551
-
552
-            Spacer()
553
-
554
-            if meter.operationalState > .notPresent {
555
-                RSSIView(RSSI: meter.btSerial.averageRSSI)
556
-                    .frame(width: 18, height: 18)
557
-            }
558
-        }
559
-        .padding(.horizontal, 16)
560
-        .padding(.vertical, 10)
561
-        .background(
562
-            Rectangle()
563
-                .fill(.ultraThinMaterial)
564
-                .ignoresSafeArea(edges: .top)
565
-        )
566
-        .overlay(alignment: .bottom) {
567
-            Rectangle()
568
-                .fill(Color.secondary.opacity(0.12))
569
-                .frame(height: 1)
570
-        }
571
-    }
572
-
573
-    private func settingsCard<Content: View>(
574
-        title: String,
575
-        tint: Color,
576
-        @ViewBuilder content: () -> Content
577
-    ) -> some View {
578
-        VStack(alignment: .leading, spacing: 12) {
579
-            Text(title)
580
-                .font(.headline)
581
-            content()
582
-        }
583
-        .padding(18)
584
-        .meterCard(tint: tint, fillOpacity: 0.18, strokeOpacity: 0.24)
585
-    }
586
-
587 294
     private var availableMeterTabs: [MeterTab] {
588 295
         var tabs: [MeterTab] = [.connection]
589 296
 
@@ -609,27 +316,6 @@ struct MeterView: View {
609 316
         }
610 317
     }
611 318
 
612
-    private func prefersCompactPortraitConnection(for size: CGSize) -> Bool {
613
-        size.height < 760 || size.width < 380
614
-    }
615
-
616
-    private func portraitFace<Content: View>(@ViewBuilder content: () -> Content) -> some View {
617
-        ScrollView {
618
-            content()
619
-                .frame(maxWidth: .infinity, alignment: .topLeading)
620
-                .padding(.horizontal, pageHorizontalPadding)
621
-                .padding(.vertical, pageVerticalPadding)
622
-        }
623
-    }
624
-
625
-    private func landscapeFace<Content: View>(@ViewBuilder content: () -> Content) -> some View {
626
-        content()
627
-            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
628
-        .padding(.horizontal, pageHorizontalPadding)
629
-        .padding(.vertical, pageVerticalPadding)
630
-        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
631
-    }
632
-
633 319
     private var meterBackground: some View {
634 320
         LinearGradient(
635 321
             colors: [
@@ -647,308 +333,6 @@ struct MeterView: View {
647 333
         size.width > size.height
648 334
     }
649 335
 
650
-    private func connectionCard(compact: Bool = false, showsActions: Bool = false) -> some View {
651
-        VStack(alignment: .leading, spacing: compact ? 12 : 18) {
652
-            HStack(alignment: .top) {
653
-                meterIdentity(compact: compact)
654
-                Spacer()
655
-                statusBadge
656
-            }
657
-
658
-            if compact {
659
-                Spacer(minLength: 0)
660
-            }
661
-
662
-            connectionActionArea(compact: compact)
663
-
664
-            if showsActions {
665
-                VStack(spacing: compact ? 10 : 12) {
666
-                    Rectangle()
667
-                        .fill(Color.secondary.opacity(0.12))
668
-                        .frame(height: 1)
669
-
670
-                    actionGrid(compact: compact, embedded: true)
671
-                }
672
-            }
673
-        }
674
-        .padding(compact ? 16 : 20)
675
-        .frame(maxWidth: .infinity, maxHeight: compact ? .infinity : nil, alignment: .topLeading)
676
-        .meterCard(tint: meter.color, fillOpacity: 0.22, strokeOpacity: 0.24)
677
-    }
678
-
679
-    private func meterIdentity(compact: Bool) -> some View {
680
-        HStack(alignment: .firstTextBaseline, spacing: 8) {
681
-            Text(meter.name)
682
-                .font(.system(compact ? .title3 : .title2, design: .rounded).weight(.bold))
683
-                .lineLimit(1)
684
-                .minimumScaleFactor(0.8)
685
-
686
-            Text(meter.deviceModelName)
687
-                .font((compact ? Font.caption : .subheadline).weight(.semibold))
688
-                .foregroundColor(.secondary)
689
-                .lineLimit(1)
690
-                .minimumScaleFactor(0.8)
691
-        }
692
-    }
693
-
694
-    private func actionGrid(compact: Bool = false, embedded: Bool = false) -> some View {
695
-        let currentActionHeight = compact ? CGFloat(86) : actionButtonHeight
696
-
697
-        return GeometryReader { proxy in
698
-            let buttonWidth = actionButtonWidth(for: proxy.size.width)
699
-            let stripWidth = actionStripWidth(for: buttonWidth)
700
-            let stripContent = HStack(spacing: 0) {
701
-                meterSheetButton(icon: "square.grid.2x2.fill", title: meter.dataGroupsTitle, tint: .teal, width: buttonWidth, height: currentActionHeight, compact: compact) {
702
-                    dataGroupsViewVisibility.toggle()
703
-                }
704
-                .sheet(isPresented: $dataGroupsViewVisibility) {
705
-                    DataGroupsView(visibility: $dataGroupsViewVisibility)
706
-                        .environmentObject(meter)
707
-                }
708
-
709
-                if meter.supportsRecordingView {
710
-                    actionStripDivider(height: currentActionHeight)
711
-                    meterSheetButton(icon: "gauge.with.dots.needle.50percent", title: "Charge Record", tint: .pink, width: buttonWidth, height: currentActionHeight, compact: compact) {
712
-                        recordingViewVisibility.toggle()
713
-                    }
714
-                    .sheet(isPresented: $recordingViewVisibility) {
715
-                        RecordingView(visibility: $recordingViewVisibility)
716
-                            .environmentObject(meter)
717
-                    }
718
-                }
719
-
720
-                actionStripDivider(height: currentActionHeight)
721
-                meterSheetButton(icon: "clock.arrow.circlepath", title: "App History", tint: .blue, width: buttonWidth, height: currentActionHeight, compact: compact) {
722
-                    measurementsViewVisibility.toggle()
723
-                }
724
-                .sheet(isPresented: $measurementsViewVisibility) {
725
-                    MeasurementsView(visibility: $measurementsViewVisibility)
726
-                        .environmentObject(meter.measurements)
727
-                }
728
-            }
729
-            .padding(actionStripPadding)
730
-            .frame(width: stripWidth)
731
-
732
-            HStack {
733
-                Spacer(minLength: 0)
734
-                stripContent
735
-                    .meterCard(
736
-                        tint: embedded ? meter.color : Color.secondary,
737
-                        fillOpacity: embedded ? 0.08 : 0.10,
738
-                        strokeOpacity: embedded ? 0.14 : 0.16,
739
-                        cornerRadius: embedded ? 24 : 22
740
-                    )
741
-                Spacer(minLength: 0)
742
-            }
743
-        }
744
-        .frame(height: currentActionHeight + (actionStripPadding * 2))
745
-    }
746
-
747
-    private func connectionActionArea(compact: Bool = false) -> some View {
748
-        let connected = meter.operationalState >= .peripheralConnectionPending
749
-        let tint = connected ? disconnectActionTint : connectActionTint
750
-
751
-        return Group {
752
-            if meter.operationalState == .notPresent {
753
-                HStack(spacing: 10) {
754
-                    Image(systemName: "exclamationmark.triangle.fill")
755
-                        .foregroundColor(.orange)
756
-                    Text("Not found at this time.")
757
-                        .fontWeight(.semibold)
758
-                    Spacer()
759
-                }
760
-                .padding(compact ? 12 : 16)
761
-                .meterCard(tint: .orange, fillOpacity: 0.14, strokeOpacity: 0.18)
762
-            } else {
763
-                Button(action: {
764
-                    if meter.operationalState < .peripheralConnectionPending {
765
-                        meter.connect()
766
-                    } else {
767
-                        meter.disconnect()
768
-                    }
769
-                }) {
770
-                    HStack(spacing: 12) {
771
-                        Image(systemName: connected ? "xmark.circle.fill" : "bolt.horizontal.circle.fill")
772
-                            .foregroundColor(tint)
773
-                            .frame(width: 30, height: 30)
774
-                            .background(Circle().fill(tint.opacity(0.12)))
775
-                        Text(connected ? "Disconnect" : "Connect")
776
-                            .fontWeight(.semibold)
777
-                            .foregroundColor(.primary)
778
-                        Spacer()
779
-                    }
780
-                    .padding(.horizontal, 18)
781
-                    .padding(.vertical, compact ? 10 : 14)
782
-                    .frame(maxWidth: .infinity)
783
-                    .meterCard(tint: tint, fillOpacity: 0.14, strokeOpacity: 0.20)
784
-                }
785
-                .buttonStyle(.plain)
786
-            }
787
-        }
788
-    }
789
-
790
-    fileprivate func meterSheetButton(icon: String, title: String, tint: Color, width: CGFloat, height: CGFloat, compact: Bool = false, action: @escaping () -> Void) -> some View {
791
-        Button(action: action) {
792
-            VStack(spacing: compact ? 8 : 10) {
793
-                Image(systemName: icon)
794
-                    .font(.system(size: compact ? 18 : 20, weight: .semibold))
795
-                    .frame(width: compact ? 34 : 40, height: compact ? 34 : 40)
796
-                    .background(Circle().fill(tint.opacity(0.14)))
797
-                Text(title)
798
-                    .font((compact ? Font.caption : .footnote).weight(.semibold))
799
-                    .multilineTextAlignment(.center)
800
-                    .lineLimit(2)
801
-                    .minimumScaleFactor(0.9)
802
-            }
803
-            .foregroundColor(tint)
804
-            .frame(width: width, height: height)
805
-            .contentShape(Rectangle())
806
-        }
807
-        .buttonStyle(.plain)
808
-    }
809
-
810
-    private var visibleActionButtonCount: CGFloat {
811
-        meter.supportsRecordingView ? 3 : 2
812
-    }
813
-
814
-    private func actionButtonWidth(for availableWidth: CGFloat) -> CGFloat {
815
-        let dividerWidth = actionDividerWidth * max(visibleActionButtonCount - 1, 0)
816
-        let contentWidth = availableWidth - (actionStripPadding * 2) - dividerWidth
817
-        let fittedWidth = floor(contentWidth / visibleActionButtonCount)
818
-        return min(actionButtonMaxWidth, max(actionButtonMinWidth, fittedWidth))
819
-    }
820
-
821
-    private func actionStripWidth(for buttonWidth: CGFloat) -> CGFloat {
822
-        let dividerWidth = actionDividerWidth * max(visibleActionButtonCount - 1, 0)
823
-        return (buttonWidth * visibleActionButtonCount) + dividerWidth + (actionStripPadding * 2)
824
-    }
825
-
826
-    private func actionStripDivider(height: CGFloat) -> some View {
827
-        Rectangle()
828
-            .fill(Color.secondary.opacity(0.16))
829
-            .frame(width: actionDividerWidth, height: max(44, height - 22))
830
-    }
831
-
832
-    private var statusBadge: some View {
833
-        Text(statusText)
834
-            .font(.caption.weight(.bold))
835
-            .padding(.horizontal, 12)
836
-            .padding(.vertical, 6)
837
-            .meterCard(tint: statusColor, fillOpacity: 0.24, strokeOpacity: 0.30, cornerRadius: 999)
838
-    }
839
-
840
-    private var connectActionTint: Color {
841
-        Color(red: 0.20, green: 0.46, blue: 0.43)
842
-    }
843
-
844
-    private var disconnectActionTint: Color {
845
-        Color(red: 0.66, green: 0.39, blue: 0.35)
846
-    }
847
-
848
-    private var statusText: String {
849
-        switch meter.operationalState {
850
-        case .notPresent:
851
-            return "Missing"
852
-        case .peripheralNotConnected:
853
-            return "Ready"
854
-        case .peripheralConnectionPending:
855
-            return "Connecting"
856
-        case .peripheralConnected:
857
-            return "Linked"
858
-        case .peripheralReady:
859
-            return "Preparing"
860
-        case .comunicating:
861
-            return "Syncing"
862
-        case .dataIsAvailable:
863
-            return "Live"
864
-        }
865
-    }
866
-
867
-    private var statusColor: Color {
868
-        Meter.operationalColor(for: meter.operationalState)
869
-    }
870
-}
871
-
872
-
873
-private struct MeterInfoCard<Content: View>: View {
874
-    let title: String
875
-    let tint: Color
876
-    @ViewBuilder var content: Content
877
-
878
-    var body: some View {
879
-        VStack(alignment: .leading, spacing: 12) {
880
-            Text(title)
881
-                .font(.headline)
882
-            content
883
-        }
884
-        .frame(maxWidth: .infinity, alignment: .leading)
885
-        .padding(18)
886
-        .meterCard(tint: tint, fillOpacity: 0.18, strokeOpacity: 0.24)
887
-    }
888
-}
889
-
890
-private struct MeterInfoRow: View {
891
-    let label: String
892
-    let value: String
893
-
894
-    var body: some View {
895
-        HStack {
896
-            Text(label)
897
-            Spacer()
898
-            Text(value)
899
-                .foregroundColor(.secondary)
900
-                .multilineTextAlignment(.trailing)
901
-        }
902
-        .font(.footnote)
903
-    }
904
-}
905
-
906
-private struct EditNameView: View {
907
-
908
-    @EnvironmentObject private var meter: Meter
909
-
910
-    @Binding var editingName: Bool
911
-    @State var newName: String
912
-
913
-    var body: some View {
914
-        TextField("Name", text: self.$newName, onCommit: {
915
-            self.meter.name = self.newName
916
-            self.editingName = false
917
-        })
918
-            .textFieldStyle(RoundedBorderTextFieldStyle())
919
-            .lineLimit(1)
920
-            .disableAutocorrection(true)
921
-            .multilineTextAlignment(.center)
922
-    }
923
-}
924
-
925
-private struct EditScreenTimeoutView: View {
926
-
927
-    @EnvironmentObject private var meter: Meter
928
-
929
-    var body: some View {
930
-        Picker("", selection: self.$meter.screenTimeout ) {
931
-            ForEach(1...9, id: \.self) { value in
932
-                Text("\(value)").tag(value)
933
-            }
934
-            Text("Off").tag(0)
935
-        }
936
-        .pickerStyle(SegmentedPickerStyle())
937
-    }
938
-}
939
-
940
-private struct EditScreenBrightnessView: View {
941
-
942
-    @EnvironmentObject private var meter: Meter
943
-
944
-    var body: some View {
945
-        Picker("", selection: self.$meter.screenBrightness ) {
946
-            ForEach(0...5, id: \.self) { value in
947
-                Text("\(value)").tag(value)
948
-            }
949
-        }
950
-        .pickerStyle(SegmentedPickerStyle())
951
-    }
952 336
 }
953 337
 
954 338
 // MARK: - Conditional navigation bar modifier (skipped on Designed-for-iPad / Mac)
+36 -0
USB Meter/Views/Meter/Screens/Control/Components/ControlActionButtonView.swift
@@ -0,0 +1,36 @@
1
+//
2
+//  ControlActionButtonView.swift
3
+//  USB Meter
4
+//
5
+//  Created by Bogdan Timofte on 29/03/2026.
6
+//  Co-authored-by: GPT-5.3-Codex.
7
+//  Copyright © 2026 Bogdan Timofte. All rights reserved.
8
+//
9
+
10
+import SwiftUI
11
+
12
+struct ControlActionButtonView: View {
13
+    let title: String
14
+    let symbol: String
15
+    let tint: Color
16
+    let compact: Bool
17
+    let isExpandedCompactLayout: Bool
18
+    let action: () -> Void
19
+
20
+    var body: some View {
21
+        Button(action: action) {
22
+            VStack(spacing: 10) {
23
+                Image(systemName: symbol)
24
+                    .font(.system(size: compact ? 18 : 20, weight: .semibold))
25
+                Text(title)
26
+                    .font(.footnote.weight(.semibold))
27
+                    .multilineTextAlignment(.center)
28
+            }
29
+            .foregroundColor(tint)
30
+            .frame(maxWidth: .infinity, minHeight: compact ? (isExpandedCompactLayout ? 112 : 92) : 68)
31
+            .padding(.horizontal, 8)
32
+            .meterCard(tint: tint, fillOpacity: 0.10, strokeOpacity: 0.14)
33
+        }
34
+        .buttonStyle(.plain)
35
+    }
36
+}
+30 -0
USB Meter/Views/Meter/Screens/Control/Components/ControlCurrentScreenCardView.swift
@@ -0,0 +1,30 @@
1
+//
2
+//  ControlCurrentScreenCardView.swift
3
+//  USB Meter
4
+//
5
+//  Created by Bogdan Timofte on 29/03/2026.
6
+//  Co-authored-by: GPT-5.3-Codex.
7
+//  Copyright © 2026 Bogdan Timofte. All rights reserved.
8
+//
9
+
10
+import SwiftUI
11
+
12
+struct ControlCurrentScreenCardView: View {
13
+    let reportsCurrentScreenIndex: Bool
14
+    let currentScreenDescription: String
15
+    let isExpandedCompactLayout: Bool
16
+
17
+    var body: some View {
18
+        if reportsCurrentScreenIndex {
19
+            Text(currentScreenDescription)
20
+                .font((isExpandedCompactLayout ? Font.title3 : .subheadline).weight(.semibold))
21
+                .multilineTextAlignment(.center)
22
+        } else {
23
+            VStack {
24
+                Image(systemName: "questionmark.square.dashed")
25
+                    .font(.system(size: isExpandedCompactLayout ? 30 : 24, weight: .semibold))
26
+                    .foregroundColor(.secondary)
27
+            }
28
+        }
29
+    }
30
+}
+41 -49
USB Meter/Views/Meter/ControlView.swift → USB Meter/Views/Meter/Screens/Control/ControlView.swift
@@ -34,31 +34,41 @@ struct ControlView: View {
34 34
 
35 35
                     VStack(spacing: 12) {
36 36
                         HStack(spacing: 12) {
37
-                            controlButton(
37
+                            ControlActionButtonView(
38 38
                                 title: "Prev",
39 39
                                 symbol: "chevron.left",
40 40
                                 tint: .indigo,
41
+                                compact: true,
42
+                                isExpandedCompactLayout: usesExpandedCompactLayout,
41 43
                                 action: { meter.previousScreen() }
42 44
                             )
43 45
 
44
-                            currentScreenCard
46
+                            ControlCurrentScreenCardView(
47
+                                reportsCurrentScreenIndex: meter.reportsCurrentScreenIndex,
48
+                                currentScreenDescription: meter.currentScreenDescription,
49
+                                isExpandedCompactLayout: usesExpandedCompactLayout
50
+                            )
45 51
                                 .frame(maxWidth: .infinity, minHeight: 112)
46 52
                                 .padding(.horizontal, 14)
47 53
                                 .meterCard(tint: meter.color, fillOpacity: 0.06, strokeOpacity: 0.10)
48 54
                         }
49 55
 
50 56
                         HStack(spacing: 12) {
51
-                            controlButton(
57
+                            ControlActionButtonView(
52 58
                                 title: "Rotate",
53 59
                                 symbol: "rotate.right.fill",
54 60
                                 tint: .orange,
61
+                                compact: true,
62
+                                isExpandedCompactLayout: usesExpandedCompactLayout,
55 63
                                 action: { meter.rotateScreen() }
56 64
                             )
57 65
 
58
-                            controlButton(
66
+                            ControlActionButtonView(
59 67
                                 title: "Next",
60 68
                                 symbol: "chevron.right",
61 69
                                 tint: .indigo,
70
+                                compact: true,
71
+                                isExpandedCompactLayout: usesExpandedCompactLayout,
62 72
                                 action: { meter.nextScreen() }
63 73
                             )
64 74
                         }
@@ -67,60 +77,79 @@ struct ControlView: View {
67 77
                     Spacer(minLength: 0)
68 78
                 } else {
69 79
                     HStack(spacing: 10) {
70
-                        controlButton(
80
+                        ControlActionButtonView(
71 81
                             title: "Prev",
72 82
                             symbol: "chevron.left",
73 83
                             tint: .indigo,
84
+                            compact: true,
85
+                            isExpandedCompactLayout: usesExpandedCompactLayout,
74 86
                             action: { meter.previousScreen() }
75 87
                         )
76 88
 
77
-                        currentScreenCard
89
+                        ControlCurrentScreenCardView(
90
+                            reportsCurrentScreenIndex: meter.reportsCurrentScreenIndex,
91
+                            currentScreenDescription: meter.currentScreenDescription,
92
+                            isExpandedCompactLayout: usesExpandedCompactLayout
93
+                        )
78 94
                             .frame(maxWidth: .infinity, minHeight: 82)
79 95
                             .padding(.horizontal, 10)
80 96
                             .meterCard(tint: meter.color, fillOpacity: 0.06, strokeOpacity: 0.10)
81 97
 
82
-                        controlButton(
98
+                        ControlActionButtonView(
83 99
                             title: "Rotate",
84 100
                             symbol: "rotate.right.fill",
85 101
                             tint: .orange,
102
+                            compact: true,
103
+                            isExpandedCompactLayout: usesExpandedCompactLayout,
86 104
                             action: { meter.rotateScreen() }
87 105
                         )
88 106
 
89
-                        controlButton(
107
+                        ControlActionButtonView(
90 108
                             title: "Next",
91 109
                             symbol: "chevron.right",
92 110
                             tint: .indigo,
111
+                            compact: true,
112
+                            isExpandedCompactLayout: usesExpandedCompactLayout,
93 113
                             action: { meter.nextScreen() }
94 114
                         )
95 115
                     }
96 116
                 }
97 117
             } else {
98 118
                 HStack(spacing: 12) {
99
-                    controlButton(
119
+                    ControlActionButtonView(
100 120
                         title: "Prev",
101 121
                         symbol: "chevron.left",
102 122
                         tint: .indigo,
123
+                        compact: true,
124
+                        isExpandedCompactLayout: usesExpandedCompactLayout,
103 125
                         action: { meter.previousScreen() }
104 126
                     )
105 127
 
106
-                    currentScreenCard
128
+                    ControlCurrentScreenCardView(
129
+                        reportsCurrentScreenIndex: meter.reportsCurrentScreenIndex,
130
+                        currentScreenDescription: meter.currentScreenDescription,
131
+                        isExpandedCompactLayout: usesExpandedCompactLayout
132
+                    )
107 133
                     .frame(maxWidth: .infinity, minHeight: 92)
108 134
                     .padding(.horizontal, 12)
109 135
                     .meterCard(tint: meter.color, fillOpacity: 0.06, strokeOpacity: 0.10)
110 136
 
111
-                    controlButton(
137
+                    ControlActionButtonView(
112 138
                         title: "Next",
113 139
                         symbol: "chevron.right",
114 140
                         tint: .indigo,
141
+                        compact: true,
142
+                        isExpandedCompactLayout: usesExpandedCompactLayout,
115 143
                         action: { meter.nextScreen() }
116 144
                     )
117 145
                 }
118 146
 
119
-                controlButton(
147
+                ControlActionButtonView(
120 148
                     title: "Rotate Screen",
121 149
                     symbol: "rotate.right.fill",
122 150
                     tint: .orange,
123 151
                     compact: false,
152
+                    isExpandedCompactLayout: usesExpandedCompactLayout,
124 153
                     action: { meter.rotateScreen() }
125 154
                 )
126 155
             }
@@ -128,47 +157,10 @@ struct ControlView: View {
128 157
         .frame(maxWidth: .infinity, maxHeight: compactLayout ? .infinity : nil, alignment: .topLeading)
129 158
     }
130 159
 
131
-    @ViewBuilder
132
-    private var currentScreenCard: some View {
133
-        if meter.reportsCurrentScreenIndex {
134
-            Text(meter.currentScreenDescription)
135
-                .font((usesExpandedCompactLayout ? Font.title3 : .subheadline).weight(.semibold))
136
-                .multilineTextAlignment(.center)
137
-        } else {
138
-            VStack {
139
-                Image(systemName: "questionmark.square.dashed")
140
-                    .font(.system(size: usesExpandedCompactLayout ? 30 : 24, weight: .semibold))
141
-                    .foregroundColor(.secondary)
142
-            }
143
-        }
144
-    }
145
-
146 160
     private var usesExpandedCompactLayout: Bool {
147 161
         compactLayout && (availableSize?.height ?? 0) >= 520
148 162
     }
149 163
 
150
-    private func controlButton(
151
-        title: String,
152
-        symbol: String,
153
-        tint: Color,
154
-        compact: Bool = true,
155
-        action: @escaping () -> Void
156
-    ) -> some View {
157
-        Button(action: action) {
158
-            VStack(spacing: 10) {
159
-                Image(systemName: symbol)
160
-                    .font(.system(size: compact ? 18 : 20, weight: .semibold))
161
-                Text(title)
162
-                    .font(.footnote.weight(.semibold))
163
-                    .multilineTextAlignment(.center)
164
-            }
165
-            .foregroundColor(tint)
166
-            .frame(maxWidth: .infinity, minHeight: compact ? (usesExpandedCompactLayout ? 112 : 92) : 68)
167
-            .padding(.horizontal, 8)
168
-            .meterCard(tint: tint, fillOpacity: 0.10, strokeOpacity: 0.14)
169
-        }
170
-        .buttonStyle(.plain)
171
-    }
172 164
 }
173 165
 
174 166
 struct ControlView_Previews: PreviewProvider {
+13 -0
USB Meter/Views/Meter/Screens/Live/Components/LiveMetricRange.swift
@@ -0,0 +1,13 @@
1
+//
2
+//  LiveMetricRange.swift
3
+//  USB Meter
4
+//
5
+
6
+import Foundation
7
+
8
+struct LiveMetricRange {
9
+    let minLabel: String
10
+    let maxLabel: String
11
+    let minValue: String
12
+    let maxValue: String
13
+}
+60 -0
USB Meter/Views/Meter/Screens/Live/Components/LoadResistanceSymbolView.swift
@@ -0,0 +1,60 @@
1
+//
2
+//  LoadResistanceSymbolView.swift
3
+//  USB Meter
4
+//
5
+
6
+import SwiftUI
7
+
8
+struct LoadResistanceSymbolView: View {
9
+    let color: Color
10
+
11
+    var body: some View {
12
+        GeometryReader { proxy in
13
+            let width = proxy.size.width
14
+            let height = proxy.size.height
15
+            let midY = height / 2
16
+            let startX = width * 0.10
17
+            let endX = width * 0.90
18
+            let boxMinX = width * 0.28
19
+            let boxMaxX = width * 0.72
20
+            let boxHeight = height * 0.34
21
+            let boxRect = CGRect(
22
+                x: boxMinX,
23
+                y: midY - (boxHeight / 2),
24
+                width: boxMaxX - boxMinX,
25
+                height: boxHeight
26
+            )
27
+            let strokeWidth = max(1.2, height * 0.055)
28
+
29
+            ZStack {
30
+                Path { path in
31
+                    path.move(to: CGPoint(x: startX, y: midY))
32
+                    path.addLine(to: CGPoint(x: boxRect.minX, y: midY))
33
+                    path.move(to: CGPoint(x: boxRect.maxX, y: midY))
34
+                    path.addLine(to: CGPoint(x: endX, y: midY))
35
+                }
36
+                .stroke(
37
+                    color,
38
+                    style: StrokeStyle(
39
+                        lineWidth: strokeWidth,
40
+                        lineCap: .round,
41
+                        lineJoin: .round
42
+                    )
43
+                )
44
+
45
+                Path { path in
46
+                    path.addRect(boxRect)
47
+                }
48
+                .stroke(
49
+                    color,
50
+                    style: StrokeStyle(
51
+                        lineWidth: strokeWidth,
52
+                        lineCap: .round,
53
+                        lineJoin: .round
54
+                    )
55
+                )
56
+            }
57
+        }
58
+        .padding(4)
59
+    }
60
+}
+8 -69
USB Meter/Views/Meter/LiveView.swift → USB Meter/Views/Meter/Screens/Live/LiveView.swift
@@ -9,67 +9,6 @@
9 9
 import SwiftUI
10 10
 
11 11
 struct LiveView: View {
12
-    private struct MetricRange {
13
-        let minLabel: String
14
-        let maxLabel: String
15
-        let minValue: String
16
-        let maxValue: String
17
-    }
18
-
19
-    private struct LoadResistanceSymbol: View {
20
-        let color: Color
21
-
22
-        var body: some View {
23
-            GeometryReader { proxy in
24
-                let width = proxy.size.width
25
-                let height = proxy.size.height
26
-                let midY = height / 2
27
-                let startX = width * 0.10
28
-                let endX = width * 0.90
29
-                let boxMinX = width * 0.28
30
-                let boxMaxX = width * 0.72
31
-                let boxHeight = height * 0.34
32
-                let boxRect = CGRect(
33
-                    x: boxMinX,
34
-                    y: midY - (boxHeight / 2),
35
-                    width: boxMaxX - boxMinX,
36
-                    height: boxHeight
37
-                )
38
-                let strokeWidth = max(1.2, height * 0.055)
39
-
40
-                ZStack {
41
-                    Path { path in
42
-                        path.move(to: CGPoint(x: startX, y: midY))
43
-                        path.addLine(to: CGPoint(x: boxRect.minX, y: midY))
44
-                        path.move(to: CGPoint(x: boxRect.maxX, y: midY))
45
-                        path.addLine(to: CGPoint(x: endX, y: midY))
46
-                    }
47
-                    .stroke(
48
-                        color,
49
-                        style: StrokeStyle(
50
-                            lineWidth: strokeWidth,
51
-                            lineCap: .round,
52
-                            lineJoin: .round
53
-                        )
54
-                    )
55
-
56
-                    Path { path in
57
-                        path.addRect(boxRect)
58
-                    }
59
-                    .stroke(
60
-                        color,
61
-                        style: StrokeStyle(
62
-                            lineWidth: strokeWidth,
63
-                            lineCap: .round,
64
-                            lineJoin: .round
65
-                        )
66
-                    )
67
-                }
68
-            }
69
-            .padding(4)
70
-        }
71
-    }
72
-    
73 12
     @EnvironmentObject private var meter: Meter
74 13
     var compactLayout: Bool = false
75 14
     var availableSize: CGSize? = nil
@@ -130,7 +69,7 @@ struct LiveView: View {
130 69
 
131 70
                 liveMetricCard(
132 71
                     title: "Load",
133
-                    customSymbol: AnyView(LoadResistanceSymbol(color: .yellow)),
72
+                    customSymbol: AnyView(LoadResistanceSymbolView(color: .yellow)),
134 73
                     color: .yellow,
135 74
                     value: "\(meter.loadResistance.format(decimalDigits: 1)) \u{2126}",
136 75
                     detailText: "Measured resistance"
@@ -141,7 +80,7 @@ struct LiveView: View {
141 80
                     symbol: "dot.radiowaves.left.and.right",
142 81
                     color: .mint,
143 82
                     value: "\(meter.btSerial.averageRSSI) dBm",
144
-                    range: MetricRange(
83
+                    range: LiveMetricRange(
145 84
                         minLabel: "Min",
146 85
                         maxLabel: "Max",
147 86
                         minValue: "\(meter.btSerial.minRSSI) dBm",
@@ -190,7 +129,7 @@ struct LiveView: View {
190 129
         customSymbol: AnyView? = nil,
191 130
         color: Color,
192 131
         value: String,
193
-        range: MetricRange? = nil,
132
+        range: LiveMetricRange? = nil,
194 133
         detailText: String? = nil,
195 134
         valueFont: Font? = nil,
196 135
         valueLineLimit: Int = 1,
@@ -251,7 +190,7 @@ struct LiveView: View {
251 190
         .meterCard(tint: color, fillOpacity: 0.10, strokeOpacity: 0.12)
252 191
     }
253 192
 
254
-    private func metricRangeTable(_ range: MetricRange) -> some View {
193
+    private func metricRangeTable(_ range: LiveMetricRange) -> some View {
255 194
         VStack(alignment: .leading, spacing: 4) {
256 195
             HStack(spacing: 12) {
257 196
                 Text(range.minLabel)
@@ -273,10 +212,10 @@ struct LiveView: View {
273 212
         }
274 213
     }
275 214
 
276
-    private func metricRange(min: Double, max: Double, unit: String) -> MetricRange? {
215
+    private func metricRange(min: Double, max: Double, unit: String) -> LiveMetricRange? {
277 216
         guard min.isFinite, max.isFinite else { return nil }
278 217
 
279
-        return MetricRange(
218
+        return LiveMetricRange(
280 219
             minLabel: "Min",
281 220
             maxLabel: "Max",
282 221
             minValue: "\(min.format(decimalDigits: 3)) \(unit)",
@@ -284,11 +223,11 @@ struct LiveView: View {
284 223
         )
285 224
     }
286 225
 
287
-    private func temperatureRange() -> MetricRange? {
226
+    private func temperatureRange() -> LiveMetricRange? {
288 227
         let value = meter.primaryTemperatureDescription
289 228
         guard !value.isEmpty else { return nil }
290 229
 
291
-        return MetricRange(
230
+        return LiveMetricRange(
292 231
             minLabel: "Min",
293 232
             maxLabel: "Max",
294 233
             minValue: value,
+33 -0
USB Meter/Views/Meter/Screens/Recording/Components/RecordingMetricsTableView.swift
@@ -0,0 +1,33 @@
1
+//
2
+//  RecordingMetricsTableView.swift
3
+//  USB Meter
4
+//
5
+//  Created by Bogdan Timofte on 29/03/2026.
6
+//  Co-authored-by: GPT-5.3-Codex.
7
+//  Copyright © 2026 Bogdan Timofte. All rights reserved.
8
+//
9
+
10
+import SwiftUI
11
+
12
+struct RecordingMetricsTableView: View {
13
+    let labels: [String]
14
+    let values: [String]
15
+
16
+    var body: some View {
17
+        HStack(alignment: .top) {
18
+            VStack(alignment: .leading, spacing: 10) {
19
+                ForEach(labels, id: \.self) { label in
20
+                    Text(label)
21
+                }
22
+            }
23
+            Spacer()
24
+            VStack(alignment: .trailing, spacing: 10) {
25
+                ForEach(Array(values.enumerated()), id: \.offset) { _, value in
26
+                    Text(value)
27
+                }
28
+            }
29
+            .monospacedDigit()
30
+        }
31
+        .font(.footnote.weight(.semibold))
32
+    }
33
+}
+18 -38
USB Meter/Views/Meter/RecordingView.swift → USB Meter/Views/Meter/Screens/Recording/RecordingView.swift
@@ -42,23 +42,15 @@ struct RecordingView: View {
42 42
                     .padding(18)
43 43
                     .meterCard(tint: .pink, fillOpacity: 0.18, strokeOpacity: 0.24)
44 44
 
45
-                    HStack(alignment: .top) {
46
-                        VStack(alignment: .leading, spacing: 10) {
47
-                            Text("Capacity")
48
-                            Text("Energy")
49
-                            Text("Duration")
50
-                            Text("Stop Threshold")
51
-                        }
52
-                        Spacer()
53
-                        VStack(alignment: .trailing, spacing: 10) {
54
-                            Text("\(usbMeter.chargeRecordAH.format(decimalDigits: 3)) Ah")
55
-                            Text("\(usbMeter.chargeRecordWH.format(decimalDigits: 3)) Wh")
56
-                            Text(usbMeter.chargeRecordDurationDescription)
57
-                            Text("\(usbMeter.chargeRecordStopThreshold.format(decimalDigits: 2)) A")
58
-                        }
59
-                        .monospacedDigit()
60
-                    }
61
-                    .font(.footnote.weight(.semibold))
45
+                    RecordingMetricsTableView(
46
+                        labels: ["Capacity", "Energy", "Duration", "Stop Threshold"],
47
+                        values: [
48
+                            "\(usbMeter.chargeRecordAH.format(decimalDigits: 3)) Ah",
49
+                            "\(usbMeter.chargeRecordWH.format(decimalDigits: 3)) Wh",
50
+                            usbMeter.chargeRecordDurationDescription,
51
+                            "\(usbMeter.chargeRecordStopThreshold.format(decimalDigits: 2)) A"
52
+                        ]
53
+                    )
62 54
                     .padding(18)
63 55
                     .meterCard(tint: .pink, fillOpacity: 0.14, strokeOpacity: 0.20)
64 56
 
@@ -106,27 +98,15 @@ struct RecordingView: View {
106 98
                         VStack(alignment: .leading, spacing: 12) {
107 99
                             Text("Meter Totals")
108 100
                                 .font(.headline)
109
-                            HStack(alignment: .top) {
110
-                                VStack(alignment: .leading, spacing: 10) {
111
-                                    Text("Capacity")
112
-                                    Text("Energy")
113
-                                    Text("Duration")
114
-                                    Text("Meter Threshold")
115
-                                }
116
-                                Spacer()
117
-                                VStack(alignment: .trailing, spacing: 10) {
118
-                                    Text("\(usbMeter.recordedAH.format(decimalDigits: 3)) Ah")
119
-                                    Text("\(usbMeter.recordedWH.format(decimalDigits: 3)) Wh")
120
-                                    Text(usbMeter.recordingDurationDescription)
121
-                                    if usbMeter.supportsRecordingThreshold {
122
-                                        Text("\(usbMeter.recordingTreshold.format(decimalDigits: 2)) A")
123
-                                    } else {
124
-                                        Text("Read-only")
125
-                                    }
126
-                                }
127
-                                .monospacedDigit()
128
-                            }
129
-                            .font(.footnote.weight(.semibold))
101
+                            RecordingMetricsTableView(
102
+                                labels: ["Capacity", "Energy", "Duration", "Meter Threshold"],
103
+                                values: [
104
+                                    "\(usbMeter.recordedAH.format(decimalDigits: 3)) Ah",
105
+                                    "\(usbMeter.recordedWH.format(decimalDigits: 3)) Wh",
106
+                                    usbMeter.recordingDurationDescription,
107
+                                    usbMeter.supportsRecordingThreshold ? "\(usbMeter.recordingTreshold.format(decimalDigits: 2)) A" : "Read-only"
108
+                                ]
109
+                            )
130 110
                             Text("These values are reported by the meter for the active data group.")
131 111
                                 .font(.footnote)
132 112
                                 .foregroundColor(.secondary)
+57 -0
USB Meter/Views/Meter/Tabs/Chart/MeterChartTabView.swift
@@ -0,0 +1,57 @@
1
+//
2
+//  MeterChartTabView.swift
3
+//  USB Meter
4
+//
5
+
6
+import SwiftUI
7
+
8
+struct MeterChartTabView: View {
9
+    @EnvironmentObject private var meter: Meter
10
+
11
+    let size: CGSize
12
+    let isLandscape: Bool
13
+
14
+    private let pageHorizontalPadding: CGFloat = 12
15
+    private let pageVerticalPadding: CGFloat = 12
16
+    private let contentCardPadding: CGFloat = 16
17
+
18
+    var body: some View {
19
+        Group {
20
+            if isLandscape {
21
+                landscapeFace {
22
+                    MeasurementChartView()
23
+                        .environmentObject(meter.measurements)
24
+                        .frame(height: max(250, size.height - 44))
25
+                        .padding(contentCardPadding)
26
+                        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
27
+                        .meterCard(tint: meter.color, fillOpacity: 0.12, strokeOpacity: 0.20)
28
+                }
29
+            } else {
30
+                portraitFace {
31
+                    MeasurementChartView()
32
+                        .environmentObject(meter.measurements)
33
+                        .frame(minHeight: size.height / 3.4)
34
+                        .padding(contentCardPadding)
35
+                        .meterCard(tint: meter.color, fillOpacity: 0.12, strokeOpacity: 0.20)
36
+                }
37
+            }
38
+        }
39
+    }
40
+
41
+    private func portraitFace<Content: View>(@ViewBuilder content: () -> Content) -> some View {
42
+        ScrollView {
43
+            content()
44
+                .frame(maxWidth: .infinity, alignment: .topLeading)
45
+                .padding(.horizontal, pageHorizontalPadding)
46
+                .padding(.vertical, pageVerticalPadding)
47
+        }
48
+    }
49
+
50
+    private func landscapeFace<Content: View>(@ViewBuilder content: () -> Content) -> some View {
51
+        content()
52
+            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
53
+            .padding(.horizontal, pageHorizontalPadding)
54
+            .padding(.vertical, pageVerticalPadding)
55
+            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
56
+    }
57
+}
+78 -0
USB Meter/Views/Meter/Tabs/Connection/Components/ConnectionHomeInfoPreviewView.swift
@@ -0,0 +1,78 @@
1
+//
2
+//  ConnectionHomeInfoPreviewView.swift
3
+//  USB Meter
4
+//
5
+//  Created by Bogdan Timofte on 29/03/2026.
6
+//  Co-authored-by: GPT-5.3-Codex.
7
+//  Copyright © 2026 Bogdan Timofte. All rights reserved.
8
+//
9
+
10
+import SwiftUI
11
+
12
+struct ConnectionHomeInfoPreviewView: View {
13
+    let meter: Meter
14
+
15
+    var body: some View {
16
+        VStack(spacing: 14) {
17
+            MeterInfoCard(title: "Overview", tint: meter.color) {
18
+                MeterInfoRow(label: "Name", value: meter.name)
19
+                MeterInfoRow(label: "Device Model", value: meter.deviceModelName)
20
+                MeterInfoRow(label: "Advertised Model", value: meter.modelString)
21
+                MeterInfoRow(label: "Working Voltage", value: meter.documentedWorkingVoltage)
22
+                MeterInfoRow(label: "Temperature Unit", value: meter.temperatureUnitDescription)
23
+                MeterInfoRow(label: "Last Seen", value: meterHistoryText(for: meter.lastSeen))
24
+                MeterInfoRow(label: "Last Connected", value: meterHistoryText(for: meter.lastConnectedAt))
25
+            }
26
+
27
+            MeterInfoCard(title: "Identifiers", tint: .blue) {
28
+                MeterInfoRow(label: "MAC", value: meter.btSerial.macAddress.description)
29
+                if meter.modelNumber != 0 {
30
+                    MeterInfoRow(label: "Model Identifier", value: "\(meter.modelNumber)")
31
+                }
32
+            }
33
+
34
+            MeterInfoCard(title: "Screen Reporting", tint: .orange) {
35
+                if meter.reportsCurrentScreenIndex {
36
+                    MeterInfoRow(label: "Current Screen", value: meter.currentScreenDescription)
37
+                    Text("The active screen index is reported by the meter and mapped by the app to a known label.")
38
+                        .font(.footnote)
39
+                        .foregroundColor(.secondary)
40
+                } else {
41
+                    MeterInfoRow(label: "Current Screen", value: "Not Reported")
42
+                    Text("The current screen is not reported by the device payload, or we have not yet identified where and how the protocol announces it.")
43
+                        .font(.footnote)
44
+                        .foregroundColor(.secondary)
45
+                }
46
+            }
47
+
48
+            MeterInfoCard(title: "Live Device Details", tint: .indigo) {
49
+                if meter.operationalState == .dataIsAvailable {
50
+                    if !meter.firmwareVersion.isEmpty {
51
+                        MeterInfoRow(label: "Firmware", value: meter.firmwareVersion)
52
+                    }
53
+                    if meter.supportsChargerDetection {
54
+                        MeterInfoRow(label: "Detected Charger", value: meter.chargerTypeDescription)
55
+                    }
56
+                    if meter.serialNumber != 0 {
57
+                        MeterInfoRow(label: "Serial", value: "\(meter.serialNumber)")
58
+                    }
59
+                    if meter.bootCount != 0 {
60
+                        MeterInfoRow(label: "Boot Count", value: "\(meter.bootCount)")
61
+                    }
62
+                } else {
63
+                    Text("Connect to the meter to load firmware, serial, and boot details.")
64
+                        .font(.footnote)
65
+                        .foregroundColor(.secondary)
66
+                }
67
+            }
68
+        }
69
+        .padding(.horizontal, 12)
70
+    }
71
+
72
+    private func meterHistoryText(for date: Date?) -> String {
73
+        guard let date else {
74
+            return "Never"
75
+        }
76
+        return date.format(as: "yyyy-MM-dd HH:mm")
77
+    }
78
+}
+65 -0
USB Meter/Views/Meter/Tabs/Connection/Components/ConnectionPrimaryActionView.swift
@@ -0,0 +1,65 @@
1
+//
2
+//  ConnectionPrimaryActionView.swift
3
+//  USB Meter
4
+//
5
+//  Created by Bogdan Timofte on 29/03/2026.
6
+//  Co-authored-by: GPT-5.3-Codex.
7
+//  Copyright © 2026 Bogdan Timofte. All rights reserved.
8
+//
9
+
10
+import SwiftUI
11
+
12
+struct ConnectionPrimaryActionView: View {
13
+    let operationalState: Meter.OperationalState
14
+    let compact: Bool
15
+    let connectAction: () -> Void
16
+    let disconnectAction: () -> Void
17
+
18
+    private var connected: Bool {
19
+        operationalState >= .peripheralConnectionPending
20
+    }
21
+
22
+    private var actionTint: Color {
23
+        connected ? Color(red: 0.66, green: 0.39, blue: 0.35) : Color(red: 0.20, green: 0.46, blue: 0.43)
24
+    }
25
+
26
+    var body: some View {
27
+        Group {
28
+            if operationalState == .notPresent {
29
+                HStack(spacing: 10) {
30
+                    Image(systemName: "exclamationmark.triangle.fill")
31
+                        .foregroundColor(.orange)
32
+                    Text("Not found at this time.")
33
+                        .fontWeight(.semibold)
34
+                    Spacer()
35
+                }
36
+                .padding(compact ? 12 : 16)
37
+                .meterCard(tint: .orange, fillOpacity: 0.14, strokeOpacity: 0.18)
38
+            } else {
39
+                Button(action: {
40
+                    if connected {
41
+                        disconnectAction()
42
+                    } else {
43
+                        connectAction()
44
+                    }
45
+                }) {
46
+                    HStack(spacing: 12) {
47
+                        Image(systemName: connected ? "xmark.circle.fill" : "bolt.horizontal.circle.fill")
48
+                            .foregroundColor(actionTint)
49
+                            .frame(width: 30, height: 30)
50
+                            .background(Circle().fill(actionTint.opacity(0.12)))
51
+                        Text(connected ? "Disconnect" : "Connect")
52
+                            .fontWeight(.semibold)
53
+                            .foregroundColor(.primary)
54
+                        Spacer()
55
+                    }
56
+                    .padding(.horizontal, 18)
57
+                    .padding(.vertical, compact ? 10 : 14)
58
+                    .frame(maxWidth: .infinity)
59
+                    .meterCard(tint: actionTint, fillOpacity: 0.14, strokeOpacity: 0.20)
60
+                }
61
+                .buttonStyle(.plain)
62
+            }
63
+        }
64
+    }
65
+}
+23 -0
USB Meter/Views/Meter/Tabs/Connection/Components/ConnectionStatusBadgeView.swift
@@ -0,0 +1,23 @@
1
+//
2
+//  ConnectionStatusBadgeView.swift
3
+//  USB Meter
4
+//
5
+//  Created by Bogdan Timofte on 29/03/2026.
6
+//  Co-authored-by: GPT-5.3-Codex.
7
+//  Copyright © 2026 Bogdan Timofte. All rights reserved.
8
+//
9
+
10
+import SwiftUI
11
+
12
+struct ConnectionStatusBadgeView: View {
13
+    let text: String
14
+    let color: Color
15
+
16
+    var body: some View {
17
+        Text(text)
18
+            .font(.caption.weight(.bold))
19
+            .padding(.horizontal, 12)
20
+            .padding(.vertical, 6)
21
+            .meterCard(tint: color, fillOpacity: 0.24, strokeOpacity: 0.30, cornerRadius: 999)
22
+    }
23
+}
+23 -0
USB Meter/Views/Meter/Tabs/Connection/Components/MeterInfoCard.swift
@@ -0,0 +1,23 @@
1
+//
2
+//  MeterInfoCard.swift
3
+//  USB Meter
4
+//
5
+
6
+import SwiftUI
7
+
8
+struct MeterInfoCard<Content: View>: View {
9
+    let title: String
10
+    let tint: Color
11
+    @ViewBuilder var content: Content
12
+
13
+    var body: some View {
14
+        VStack(alignment: .leading, spacing: 12) {
15
+            Text(title)
16
+                .font(.headline)
17
+            content
18
+        }
19
+        .frame(maxWidth: .infinity, alignment: .leading)
20
+        .padding(18)
21
+        .meterCard(tint: tint, fillOpacity: 0.18, strokeOpacity: 0.24)
22
+    }
23
+}
+22 -0
USB Meter/Views/Meter/Tabs/Connection/Components/MeterInfoRow.swift
@@ -0,0 +1,22 @@
1
+//
2
+//  MeterInfoRow.swift
3
+//  USB Meter
4
+//
5
+
6
+import SwiftUI
7
+
8
+struct MeterInfoRow: View {
9
+    let label: String
10
+    let value: String
11
+
12
+    var body: some View {
13
+        HStack {
14
+            Text(label)
15
+            Spacer()
16
+            Text(value)
17
+                .foregroundColor(.secondary)
18
+                .multilineTextAlignment(.trailing)
19
+        }
20
+        .font(.footnote)
21
+    }
22
+}
+245 -0
USB Meter/Views/Meter/Tabs/Connection/MeterConnectionTabView.swift
@@ -0,0 +1,245 @@
1
+//
2
+//  MeterConnectionTabView.swift
3
+//  USB Meter
4
+//
5
+
6
+import SwiftUI
7
+
8
+struct MeterConnectionTabView: View {
9
+    @EnvironmentObject private var meter: Meter
10
+
11
+    let size: CGSize
12
+    let isLandscape: Bool
13
+
14
+    @State private var dataGroupsViewVisibility = false
15
+    @State private var recordingViewVisibility = false
16
+    @State private var measurementsViewVisibility = false
17
+
18
+    private let actionStripPadding: CGFloat = 10
19
+    private let actionDividerWidth: CGFloat = 1
20
+    private let actionButtonMaxWidth: CGFloat = 156
21
+    private let actionButtonMinWidth: CGFloat = 88
22
+    private let actionButtonHeight: CGFloat = 108
23
+    private let pageHorizontalPadding: CGFloat = 12
24
+    private let pageVerticalPadding: CGFloat = 12
25
+
26
+    var body: some View {
27
+        Group {
28
+            if isLandscape {
29
+                landscapeFace {
30
+                    VStack(alignment: .leading, spacing: 12) {
31
+                        connectionCard(compact: true, showsActions: meter.operationalState == .dataIsAvailable)
32
+                        ConnectionHomeInfoPreviewView(meter: meter)
33
+                    }
34
+                    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
35
+                }
36
+            } else {
37
+                portraitFace {
38
+                    VStack(alignment: .leading, spacing: 12) {
39
+                        connectionCard(
40
+                            compact: prefersCompactPortraitConnection,
41
+                            showsActions: meter.operationalState == .dataIsAvailable
42
+                        )
43
+                        ConnectionHomeInfoPreviewView(meter: meter)
44
+                    }
45
+                }
46
+            }
47
+        }
48
+    }
49
+
50
+    private var prefersCompactPortraitConnection: Bool {
51
+        size.height < 760 || size.width < 380
52
+    }
53
+
54
+    private func portraitFace<Content: View>(@ViewBuilder content: () -> Content) -> some View {
55
+        ScrollView {
56
+            content()
57
+                .frame(maxWidth: .infinity, alignment: .topLeading)
58
+                .padding(.horizontal, pageHorizontalPadding)
59
+                .padding(.vertical, pageVerticalPadding)
60
+        }
61
+    }
62
+
63
+    private func landscapeFace<Content: View>(@ViewBuilder content: () -> Content) -> some View {
64
+        content()
65
+            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
66
+            .padding(.horizontal, pageHorizontalPadding)
67
+            .padding(.vertical, pageVerticalPadding)
68
+            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
69
+    }
70
+
71
+    private func connectionCard(compact: Bool = false, showsActions: Bool = false) -> some View {
72
+        VStack(alignment: .leading, spacing: compact ? 12 : 18) {
73
+            HStack(alignment: .top) {
74
+                meterIdentity(compact: compact)
75
+                Spacer()
76
+                statusBadge
77
+            }
78
+
79
+            if compact {
80
+                Spacer(minLength: 0)
81
+            }
82
+
83
+            connectionActionArea(compact: compact)
84
+
85
+            if showsActions {
86
+                VStack(spacing: compact ? 10 : 12) {
87
+                    Rectangle()
88
+                        .fill(Color.secondary.opacity(0.12))
89
+                        .frame(height: 1)
90
+
91
+                    actionGrid(compact: compact, embedded: true)
92
+                }
93
+            }
94
+        }
95
+        .padding(compact ? 16 : 20)
96
+        .frame(maxWidth: .infinity, maxHeight: compact ? .infinity : nil, alignment: .topLeading)
97
+        .meterCard(tint: meter.color, fillOpacity: 0.22, strokeOpacity: 0.24)
98
+    }
99
+
100
+    private func meterIdentity(compact: Bool) -> some View {
101
+        HStack(alignment: .firstTextBaseline, spacing: 8) {
102
+            Text(meter.name)
103
+                .font(.system(compact ? .title3 : .title2, design: .rounded).weight(.bold))
104
+                .lineLimit(1)
105
+                .minimumScaleFactor(0.8)
106
+
107
+            Text(meter.deviceModelName)
108
+                .font((compact ? Font.caption : .subheadline).weight(.semibold))
109
+                .foregroundColor(.secondary)
110
+                .lineLimit(1)
111
+                .minimumScaleFactor(0.8)
112
+        }
113
+    }
114
+
115
+    private func actionGrid(compact: Bool = false, embedded: Bool = false) -> some View {
116
+        let currentActionHeight = compact ? CGFloat(86) : actionButtonHeight
117
+
118
+        return GeometryReader { proxy in
119
+            let buttonWidth = actionButtonWidth(for: proxy.size.width)
120
+            let stripWidth = actionStripWidth(for: buttonWidth)
121
+            let stripContent = HStack(spacing: 0) {
122
+                meterSheetButton(icon: "square.grid.2x2.fill", title: meter.dataGroupsTitle, tint: .teal, width: buttonWidth, height: currentActionHeight, compact: compact) {
123
+                    dataGroupsViewVisibility.toggle()
124
+                }
125
+                .sheet(isPresented: $dataGroupsViewVisibility) {
126
+                    DataGroupsView(visibility: $dataGroupsViewVisibility)
127
+                        .environmentObject(meter)
128
+                }
129
+
130
+                if meter.supportsRecordingView {
131
+                    actionStripDivider(height: currentActionHeight)
132
+                    meterSheetButton(icon: "gauge.with.dots.needle.50percent", title: "Charge Record", tint: .pink, width: buttonWidth, height: currentActionHeight, compact: compact) {
133
+                        recordingViewVisibility.toggle()
134
+                    }
135
+                    .sheet(isPresented: $recordingViewVisibility) {
136
+                        RecordingView(visibility: $recordingViewVisibility)
137
+                            .environmentObject(meter)
138
+                    }
139
+                }
140
+
141
+                actionStripDivider(height: currentActionHeight)
142
+                meterSheetButton(icon: "clock.arrow.circlepath", title: "App History", tint: .blue, width: buttonWidth, height: currentActionHeight, compact: compact) {
143
+                    measurementsViewVisibility.toggle()
144
+                }
145
+                .sheet(isPresented: $measurementsViewVisibility) {
146
+                    MeasurementsView(visibility: $measurementsViewVisibility)
147
+                        .environmentObject(meter.measurements)
148
+                }
149
+            }
150
+            .padding(actionStripPadding)
151
+            .frame(width: stripWidth)
152
+
153
+            HStack {
154
+                Spacer(minLength: 0)
155
+                stripContent
156
+                    .meterCard(
157
+                        tint: embedded ? meter.color : Color.secondary,
158
+                        fillOpacity: embedded ? 0.08 : 0.10,
159
+                        strokeOpacity: embedded ? 0.14 : 0.16,
160
+                        cornerRadius: embedded ? 24 : 22
161
+                    )
162
+                Spacer(minLength: 0)
163
+            }
164
+        }
165
+        .frame(height: currentActionHeight + (actionStripPadding * 2))
166
+    }
167
+
168
+    private func connectionActionArea(compact: Bool = false) -> some View {
169
+        ConnectionPrimaryActionView(
170
+            operationalState: meter.operationalState,
171
+            compact: compact,
172
+            connectAction: { meter.connect() },
173
+            disconnectAction: { meter.disconnect() }
174
+        )
175
+    }
176
+
177
+    private func meterSheetButton(icon: String, title: String, tint: Color, width: CGFloat, height: CGFloat, compact: Bool = false, action: @escaping () -> Void) -> some View {
178
+        Button(action: action) {
179
+            VStack(spacing: compact ? 8 : 10) {
180
+                Image(systemName: icon)
181
+                    .font(.system(size: compact ? 18 : 20, weight: .semibold))
182
+                    .frame(width: compact ? 34 : 40, height: compact ? 34 : 40)
183
+                    .background(Circle().fill(tint.opacity(0.14)))
184
+                Text(title)
185
+                    .font((compact ? Font.caption : .footnote).weight(.semibold))
186
+                    .multilineTextAlignment(.center)
187
+                    .lineLimit(2)
188
+                    .minimumScaleFactor(0.9)
189
+            }
190
+            .foregroundColor(tint)
191
+            .frame(width: width, height: height)
192
+            .contentShape(Rectangle())
193
+        }
194
+        .buttonStyle(.plain)
195
+    }
196
+
197
+    private var visibleActionButtonCount: CGFloat {
198
+        meter.supportsRecordingView ? 3 : 2
199
+    }
200
+
201
+    private func actionButtonWidth(for availableWidth: CGFloat) -> CGFloat {
202
+        let dividerWidth = actionDividerWidth * max(visibleActionButtonCount - 1, 0)
203
+        let contentWidth = availableWidth - (actionStripPadding * 2) - dividerWidth
204
+        let fittedWidth = floor(contentWidth / visibleActionButtonCount)
205
+        return min(actionButtonMaxWidth, max(actionButtonMinWidth, fittedWidth))
206
+    }
207
+
208
+    private func actionStripWidth(for buttonWidth: CGFloat) -> CGFloat {
209
+        let dividerWidth = actionDividerWidth * max(visibleActionButtonCount - 1, 0)
210
+        return (buttonWidth * visibleActionButtonCount) + dividerWidth + (actionStripPadding * 2)
211
+    }
212
+
213
+    private func actionStripDivider(height: CGFloat) -> some View {
214
+        Rectangle()
215
+            .fill(Color.secondary.opacity(0.16))
216
+            .frame(width: actionDividerWidth, height: max(44, height - 22))
217
+    }
218
+
219
+    private var statusBadge: some View {
220
+        ConnectionStatusBadgeView(text: statusText, color: statusColor)
221
+    }
222
+
223
+    private var statusText: String {
224
+        switch meter.operationalState {
225
+        case .notPresent:
226
+            return "Missing"
227
+        case .peripheralNotConnected:
228
+            return "Ready"
229
+        case .peripheralConnectionPending:
230
+            return "Connecting"
231
+        case .peripheralConnected:
232
+            return "Linked"
233
+        case .peripheralReady:
234
+            return "Preparing"
235
+        case .comunicating:
236
+            return "Syncing"
237
+        case .dataIsAvailable:
238
+            return "Live"
239
+        }
240
+    }
241
+
242
+    private var statusColor: Color {
243
+        Meter.operationalColor(for: meter.operationalState)
244
+    }
245
+}
+57 -0
USB Meter/Views/Meter/Tabs/Live/MeterLiveTabView.swift
@@ -0,0 +1,57 @@
1
+//
2
+//  MeterLiveTabView.swift
3
+//  USB Meter
4
+//
5
+
6
+import SwiftUI
7
+
8
+struct MeterLiveTabView: View {
9
+    let size: CGSize
10
+    let isLandscape: Bool
11
+
12
+    private let pageHorizontalPadding: CGFloat = 12
13
+    private let pageVerticalPadding: CGFloat = 12
14
+    private let contentCardPadding: CGFloat = 16
15
+
16
+    var body: some View {
17
+        Group {
18
+            if isLandscape {
19
+                landscapeFace {
20
+                    LiveView(compactLayout: true, availableSize: size)
21
+                        .padding(contentCardPadding)
22
+                        .frame(maxWidth: .infinity, alignment: .topLeading)
23
+                        .meterCard(tint: meter.color, fillOpacity: 0.12, strokeOpacity: 0.20)
24
+                }
25
+            } else {
26
+                portraitFace {
27
+                    LiveView(compactLayout: prefersCompactPortraitConnection, availableSize: size)
28
+                        .padding(contentCardPadding)
29
+                        .meterCard(tint: meter.color, fillOpacity: 0.12, strokeOpacity: 0.20)
30
+                }
31
+            }
32
+        }
33
+    }
34
+
35
+    @EnvironmentObject private var meter: Meter
36
+
37
+    private var prefersCompactPortraitConnection: Bool {
38
+        size.height < 760 || size.width < 380
39
+    }
40
+
41
+    private func portraitFace<Content: View>(@ViewBuilder content: () -> Content) -> some View {
42
+        ScrollView {
43
+            content()
44
+                .frame(maxWidth: .infinity, alignment: .topLeading)
45
+                .padding(.horizontal, pageHorizontalPadding)
46
+                .padding(.vertical, pageVerticalPadding)
47
+        }
48
+    }
49
+
50
+    private func landscapeFace<Content: View>(@ViewBuilder content: () -> Content) -> some View {
51
+        content()
52
+            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
53
+            .padding(.horizontal, pageHorizontalPadding)
54
+            .padding(.vertical, pageVerticalPadding)
55
+            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
56
+    }
57
+}
+24 -0
USB Meter/Views/Meter/Tabs/Settings/Components/EditNameView.swift
@@ -0,0 +1,24 @@
1
+//
2
+//  EditNameView.swift
3
+//  USB Meter
4
+//
5
+
6
+import SwiftUI
7
+
8
+struct EditNameView: View {
9
+    @EnvironmentObject private var meter: Meter
10
+
11
+    @Binding var editingName: Bool
12
+    @State var newName: String
13
+
14
+    var body: some View {
15
+        TextField("Name", text: self.$newName, onCommit: {
16
+            self.meter.name = self.newName
17
+            self.editingName = false
18
+        })
19
+        .textFieldStyle(RoundedBorderTextFieldStyle())
20
+        .lineLimit(1)
21
+        .disableAutocorrection(true)
22
+        .multilineTextAlignment(.center)
23
+    }
24
+}
+19 -0
USB Meter/Views/Meter/Tabs/Settings/Components/EditScreenBrightnessView.swift
@@ -0,0 +1,19 @@
1
+//
2
+//  EditScreenBrightnessView.swift
3
+//  USB Meter
4
+//
5
+
6
+import SwiftUI
7
+
8
+struct EditScreenBrightnessView: View {
9
+    @EnvironmentObject private var meter: Meter
10
+
11
+    var body: some View {
12
+        Picker("", selection: self.$meter.screenBrightness) {
13
+            ForEach(0...5, id: \.self) { value in
14
+                Text("\(value)").tag(value)
15
+            }
16
+        }
17
+        .pickerStyle(SegmentedPickerStyle())
18
+    }
19
+}
+20 -0
USB Meter/Views/Meter/Tabs/Settings/Components/EditScreenTimeoutView.swift
@@ -0,0 +1,20 @@
1
+//
2
+//  EditScreenTimeoutView.swift
3
+//  USB Meter
4
+//
5
+
6
+import SwiftUI
7
+
8
+struct EditScreenTimeoutView: View {
9
+    @EnvironmentObject private var meter: Meter
10
+
11
+    var body: some View {
12
+        Picker("", selection: self.$meter.screenTimeout) {
13
+            ForEach(1...9, id: \.self) { value in
14
+                Text("\(value)").tag(value)
15
+            }
16
+            Text("Off").tag(0)
17
+        }
18
+        .pickerStyle(SegmentedPickerStyle())
19
+    }
20
+}
+165 -0
USB Meter/Views/Meter/Tabs/Settings/MeterSettingsTabView.swift
@@ -0,0 +1,165 @@
1
+//
2
+//  MeterSettingsTabView.swift
3
+//  USB Meter
4
+//
5
+
6
+import SwiftUI
7
+
8
+struct MeterSettingsTabView: View {
9
+    @EnvironmentObject private var meter: Meter
10
+
11
+    let isMacIPadApp: Bool
12
+    let onBackToHome: () -> Void
13
+
14
+    @State private var editingName = false
15
+    @State private var editingScreenTimeout = false
16
+    @State private var editingScreenBrightness = false
17
+
18
+    var body: some View {
19
+        VStack(spacing: 0) {
20
+            if isMacIPadApp {
21
+                settingsMacHeader
22
+            }
23
+            ScrollView {
24
+                VStack(spacing: 14) {
25
+                    settingsCard(title: "Name", tint: meter.color) {
26
+                        HStack {
27
+                            Spacer()
28
+                            if !editingName {
29
+                                Text(meter.name)
30
+                                    .foregroundColor(.secondary)
31
+                            }
32
+                            ChevronView(rotate: $editingName)
33
+                        }
34
+                        if editingName {
35
+                            EditNameView(editingName: $editingName, newName: meter.name)
36
+                        }
37
+                    }
38
+
39
+                    if meter.operationalState == .dataIsAvailable && meter.supportsManualTemperatureUnitSelection {
40
+                        settingsCard(title: "Meter Temperature Unit", tint: .orange) {
41
+                            Text("TC66 temperature is shown as degrees without assuming Celsius or Fahrenheit. Keep this matched to the unit configured on the device so you can interpret the reading correctly.")
42
+                                .font(.footnote)
43
+                                .foregroundColor(.secondary)
44
+                            Picker("", selection: $meter.tc66TemperatureUnitPreference) {
45
+                                ForEach(TemperatureUnitPreference.allCases) { unit in
46
+                                    Text(unit.title).tag(unit)
47
+                                }
48
+                            }
49
+                            .pickerStyle(SegmentedPickerStyle())
50
+                        }
51
+                    }
52
+
53
+                    if meter.operationalState == .dataIsAvailable {
54
+                        settingsCard(
55
+                            title: meter.reportsCurrentScreenIndex ? "Screen Controls" : "Page Controls",
56
+                            tint: .indigo
57
+                        ) {
58
+                            if meter.reportsCurrentScreenIndex {
59
+                                Text("Use these controls when you want to change the screen shown on the device without crowding the main meter view.")
60
+                                    .font(.footnote)
61
+                                    .foregroundColor(.secondary)
62
+                            } else {
63
+                                Text("Use these controls when you want to switch device pages without crowding the main meter view.")
64
+                                    .font(.footnote)
65
+                                    .foregroundColor(.secondary)
66
+                            }
67
+
68
+                            ControlView(showsHeader: false)
69
+                        }
70
+                    }
71
+
72
+                    if meter.operationalState == .dataIsAvailable && meter.supportsUMSettings {
73
+                        settingsCard(title: "Screen Timeout", tint: .purple) {
74
+                            HStack {
75
+                                Spacer()
76
+                                if !editingScreenTimeout {
77
+                                    Text(meter.screenTimeout > 0 ? "\(meter.screenTimeout) Minutes" : "Off")
78
+                                        .foregroundColor(.secondary)
79
+                                }
80
+                                ChevronView(rotate: $editingScreenTimeout)
81
+                            }
82
+                            if editingScreenTimeout {
83
+                                EditScreenTimeoutView()
84
+                            }
85
+                        }
86
+
87
+                        settingsCard(title: "Screen Brightness", tint: .yellow) {
88
+                            HStack {
89
+                                Spacer()
90
+                                if !editingScreenBrightness {
91
+                                    Text("\(meter.screenBrightness)")
92
+                                        .foregroundColor(.secondary)
93
+                                }
94
+                                ChevronView(rotate: $editingScreenBrightness)
95
+                            }
96
+                            if editingScreenBrightness {
97
+                                EditScreenBrightnessView()
98
+                            }
99
+                        }
100
+                    }
101
+                }
102
+                .padding()
103
+            }
104
+            .background(
105
+                LinearGradient(
106
+                    colors: [meter.color.opacity(0.14), Color.clear],
107
+                    startPoint: .topLeading,
108
+                    endPoint: .bottomTrailing
109
+                )
110
+                .ignoresSafeArea()
111
+            )
112
+        }
113
+    }
114
+
115
+    private var settingsMacHeader: some View {
116
+        HStack(spacing: 12) {
117
+            Button(action: onBackToHome) {
118
+                HStack(spacing: 4) {
119
+                    Image(systemName: "chevron.left")
120
+                        .font(.body.weight(.semibold))
121
+                    Text("Back")
122
+                }
123
+                .foregroundColor(.accentColor)
124
+            }
125
+            .buttonStyle(.plain)
126
+
127
+            Text("Meter Settings")
128
+                .font(.headline)
129
+                .lineLimit(1)
130
+
131
+            Spacer()
132
+
133
+            if meter.operationalState > .notPresent {
134
+                RSSIView(RSSI: meter.btSerial.averageRSSI)
135
+                    .frame(width: 18, height: 18)
136
+            }
137
+        }
138
+        .padding(.horizontal, 16)
139
+        .padding(.vertical, 10)
140
+        .background(
141
+            Rectangle()
142
+                .fill(.ultraThinMaterial)
143
+                .ignoresSafeArea(edges: .top)
144
+        )
145
+        .overlay(alignment: .bottom) {
146
+            Rectangle()
147
+                .fill(Color.secondary.opacity(0.12))
148
+                .frame(height: 1)
149
+        }
150
+    }
151
+
152
+    private func settingsCard<Content: View>(
153
+        title: String,
154
+        tint: Color,
155
+        @ViewBuilder content: () -> Content
156
+    ) -> some View {
157
+        VStack(alignment: .leading, spacing: 12) {
158
+            Text(title)
159
+                .font(.headline)
160
+            content()
161
+        }
162
+        .padding(18)
163
+        .meterCard(tint: tint, fillOpacity: 0.18, strokeOpacity: 0.24)
164
+    }
165
+}
+75 -0
USB Meter/Views/Sidebar/SidebarList/Components/MeterCardView.swift
@@ -0,0 +1,75 @@
1
+//
2
+//  MeterCardView.swift
3
+//  USB Meter
4
+//
5
+
6
+import SwiftUI
7
+
8
+struct MeterCardView: View {
9
+    let meterSummary: AppData.MeterSummary
10
+
11
+    var body: some View {
12
+        HStack(spacing: 14) {
13
+            Image(systemName: "sensor.tag.radiowaves.forward.fill")
14
+                .font(.system(size: 18, weight: .semibold))
15
+                .foregroundColor(meterSummary.tint)
16
+                .frame(width: 42, height: 42)
17
+                .background(
18
+                    Circle()
19
+                        .fill(meterSummary.tint.opacity(0.18))
20
+                )
21
+                .overlay(alignment: .bottomTrailing) {
22
+                    Circle()
23
+                        .fill(Color.red)
24
+                        .frame(width: 12, height: 12)
25
+                        .overlay(
26
+                            Circle()
27
+                                .stroke(Color(uiColor: .systemBackground), lineWidth: 2)
28
+                        )
29
+                }
30
+
31
+            VStack(alignment: .leading, spacing: 4) {
32
+                Text(meterSummary.displayName)
33
+                    .font(.headline)
34
+                Text(meterSummary.modelSummary)
35
+                    .font(.caption)
36
+                    .foregroundColor(.secondary)
37
+                if let advertisedName = meterSummary.advertisedName, advertisedName != meterSummary.modelSummary {
38
+                    Text("Advertised as \(advertisedName)")
39
+                        .font(.caption2)
40
+                        .foregroundColor(.secondary)
41
+                }
42
+            }
43
+
44
+            Spacer()
45
+
46
+            VStack(alignment: .trailing, spacing: 4) {
47
+                HStack(spacing: 6) {
48
+                    Circle()
49
+                        .fill(Color.red)
50
+                        .frame(width: 8, height: 8)
51
+                    Text("Missing")
52
+                        .font(.caption.weight(.semibold))
53
+                        .foregroundColor(.secondary)
54
+                }
55
+                .padding(.horizontal, 10)
56
+                .padding(.vertical, 6)
57
+                .background(
58
+                    Capsule(style: .continuous)
59
+                        .fill(Color.red.opacity(0.12))
60
+                )
61
+                .overlay(
62
+                    Capsule(style: .continuous)
63
+                        .stroke(Color.red.opacity(0.22), lineWidth: 1)
64
+                )
65
+            }
66
+        }
67
+        .padding(14)
68
+        .meterCard(
69
+            tint: meterSummary.tint,
70
+            fillOpacity: 0.16,
71
+            strokeOpacity: 0.22,
72
+            cornerRadius: 18
73
+        )
74
+    }
75
+}
+39 -0
USB Meter/Views/Sidebar/SidebarList/Components/SidebarLinkCardView.swift
@@ -0,0 +1,39 @@
1
+//
2
+//  SidebarLinkCardView.swift
3
+//  USB Meter
4
+//
5
+
6
+import SwiftUI
7
+
8
+struct SidebarLinkCardView: View {
9
+    let title: String
10
+    let subtitle: String
11
+    let symbol: String
12
+    let tint: Color
13
+
14
+    var body: some View {
15
+        HStack(spacing: 14) {
16
+            Image(systemName: symbol)
17
+                .font(.system(size: 18, weight: .semibold))
18
+                .foregroundColor(tint)
19
+                .frame(width: 42, height: 42)
20
+                .background(Circle().fill(tint.opacity(0.18)))
21
+
22
+            VStack(alignment: .leading, spacing: 4) {
23
+                Text(title)
24
+                    .font(.headline)
25
+                Text(subtitle)
26
+                    .font(.caption)
27
+                    .foregroundColor(.secondary)
28
+            }
29
+
30
+            Spacer()
31
+
32
+            Image(systemName: "chevron.right")
33
+                .font(.footnote.weight(.bold))
34
+                .foregroundColor(.secondary)
35
+        }
36
+        .padding(14)
37
+        .meterCard(tint: tint, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 18)
38
+    }
39
+}
+63 -0
USB Meter/Views/Sidebar/SidebarList/Logic/SidebarAutoHelpResolver.swift
@@ -0,0 +1,63 @@
1
+//
2
+//  SidebarAutoHelpResolver.swift
3
+//  USB Meter
4
+//
5
+
6
+import Foundation
7
+import CoreBluetooth
8
+
9
+enum SidebarAutoHelpResolver {
10
+    static func activeReason(
11
+        managerState: CBManagerState,
12
+        cloudAvailability: MeterNameStore.CloudAvailability,
13
+        hasLiveMeters: Bool,
14
+        scanStartedAt: Date?,
15
+        now: Date,
16
+        noDevicesHelpDelay: TimeInterval
17
+    ) -> SidebarHelpReason? {
18
+        if managerState == .unauthorized {
19
+            return .bluetoothPermission
20
+        }
21
+        if shouldPromptForCloudSync(cloudAvailability) {
22
+            return .cloudSyncUnavailable
23
+        }
24
+        if hasWaitedLongEnoughForDevices(
25
+            managerState: managerState,
26
+            hasLiveMeters: hasLiveMeters,
27
+            scanStartedAt: scanStartedAt,
28
+            now: now,
29
+            noDevicesHelpDelay: noDevicesHelpDelay
30
+        ) {
31
+            return .noDevicesDetected
32
+        }
33
+        return nil
34
+    }
35
+
36
+    private static func shouldPromptForCloudSync(_ cloudAvailability: MeterNameStore.CloudAvailability) -> Bool {
37
+        switch cloudAvailability {
38
+        case .noAccount, .error:
39
+            return true
40
+        case .unknown, .available:
41
+            return false
42
+        }
43
+    }
44
+
45
+    private static func hasWaitedLongEnoughForDevices(
46
+        managerState: CBManagerState,
47
+        hasLiveMeters: Bool,
48
+        scanStartedAt: Date?,
49
+        now: Date,
50
+        noDevicesHelpDelay: TimeInterval
51
+    ) -> Bool {
52
+        guard managerState == .poweredOn else {
53
+            return false
54
+        }
55
+        guard hasLiveMeters == false else {
56
+            return false
57
+        }
58
+        guard let scanStartedAt else {
59
+            return false
60
+        }
61
+        return now.timeIntervalSince(scanStartedAt) >= noDevicesHelpDelay
62
+    }
63
+}
+141 -0
USB Meter/Views/Sidebar/SidebarList/Sections/ContentSidebarHelpSectionView.swift
@@ -0,0 +1,141 @@
1
+//
2
+//  ContentSidebarHelpSectionView.swift
3
+//  USB Meter
4
+//
5
+
6
+import SwiftUI
7
+
8
+struct ContentSidebarHelpSectionView<BluetoothHelpDestination: View, DeviceHelpDestination: View>: View {
9
+    let activeReason: SidebarHelpReason?
10
+    let isExpanded: Bool
11
+    let bluetoothStatusTint: Color
12
+    let bluetoothStatusText: String
13
+    let cloudSyncHelpTitle: String
14
+    let cloudSyncHelpMessage: String
15
+    let onToggle: () -> Void
16
+    let onOpenSettings: () -> Void
17
+    let bluetoothHelpDestination: BluetoothHelpDestination
18
+    let deviceHelpDestination: DeviceHelpDestination
19
+
20
+    init(
21
+        activeReason: SidebarHelpReason?,
22
+        isExpanded: Bool,
23
+        bluetoothStatusTint: Color,
24
+        bluetoothStatusText: String,
25
+        cloudSyncHelpTitle: String,
26
+        cloudSyncHelpMessage: String,
27
+        onToggle: @escaping () -> Void,
28
+        onOpenSettings: @escaping () -> Void,
29
+        @ViewBuilder bluetoothHelpDestination: () -> BluetoothHelpDestination,
30
+        @ViewBuilder deviceHelpDestination: () -> DeviceHelpDestination
31
+    ) {
32
+        self.activeReason = activeReason
33
+        self.isExpanded = isExpanded
34
+        self.bluetoothStatusTint = bluetoothStatusTint
35
+        self.bluetoothStatusText = bluetoothStatusText
36
+        self.cloudSyncHelpTitle = cloudSyncHelpTitle
37
+        self.cloudSyncHelpMessage = cloudSyncHelpMessage
38
+        self.onToggle = onToggle
39
+        self.onOpenSettings = onOpenSettings
40
+        self.bluetoothHelpDestination = bluetoothHelpDestination()
41
+        self.deviceHelpDestination = deviceHelpDestination()
42
+    }
43
+
44
+    var body: some View {
45
+        Section(header: Text("Help & Troubleshooting").font(.headline)) {
46
+            Button(action: onToggle) {
47
+                HStack(spacing: 14) {
48
+                    Image(systemName: sectionSymbol)
49
+                        .font(.system(size: 18, weight: .semibold))
50
+                        .foregroundColor(sectionTint)
51
+                        .frame(width: 42, height: 42)
52
+                        .background(Circle().fill(sectionTint.opacity(0.18)))
53
+
54
+                    Text("Help")
55
+                        .font(.headline)
56
+
57
+                    Spacer()
58
+
59
+                    if let activeReason {
60
+                        Text(activeReason.badgeTitle)
61
+                            .font(.caption2.weight(.bold))
62
+                            .foregroundColor(activeReason.tint)
63
+                            .padding(.horizontal, 10)
64
+                            .padding(.vertical, 6)
65
+                            .background(
66
+                                Capsule(style: .continuous)
67
+                                    .fill(activeReason.tint.opacity(0.12))
68
+                            )
69
+                            .overlay(
70
+                                Capsule(style: .continuous)
71
+                                    .stroke(activeReason.tint.opacity(0.22), lineWidth: 1)
72
+                            )
73
+                    }
74
+
75
+                    Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
76
+                        .font(.footnote.weight(.bold))
77
+                        .foregroundColor(.secondary)
78
+                }
79
+                .padding(14)
80
+                .meterCard(tint: sectionTint, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 18)
81
+            }
82
+            .buttonStyle(.plain)
83
+
84
+            if isExpanded {
85
+                if let activeReason {
86
+                    SidebarHelpNoticeCardView(
87
+                        reason: activeReason,
88
+                        cloudSyncHelpTitle: cloudSyncHelpTitle,
89
+                        cloudSyncHelpMessage: cloudSyncHelpMessage
90
+                    )
91
+                }
92
+
93
+                SidebarBluetoothStatusCardView(
94
+                    tint: bluetoothStatusTint,
95
+                    statusText: bluetoothStatusText
96
+                )
97
+
98
+                if activeReason == .cloudSyncUnavailable {
99
+                    Button(action: onOpenSettings) {
100
+                        SidebarLinkCardView(
101
+                            title: "Open Settings",
102
+                            subtitle: "Check Apple ID, iCloud Drive, and any restrictions affecting Cloud sync.",
103
+                            symbol: "gearshape.fill",
104
+                            tint: .indigo
105
+                        )
106
+                    }
107
+                    .buttonStyle(.plain)
108
+                }
109
+
110
+                NavigationLink(destination: bluetoothHelpDestination) {
111
+                    SidebarLinkCardView(
112
+                        title: "Bluetooth",
113
+                        subtitle: "Permissions, adapter state, and connection tips.",
114
+                        symbol: "bolt.horizontal.circle.fill",
115
+                        tint: bluetoothStatusTint
116
+                    )
117
+                }
118
+                .buttonStyle(.plain)
119
+
120
+                NavigationLink(destination: deviceHelpDestination) {
121
+                    SidebarLinkCardView(
122
+                        title: "Device",
123
+                        subtitle: "Quick checks when a meter is not responding as expected.",
124
+                        symbol: "questionmark.circle.fill",
125
+                        tint: .orange
126
+                    )
127
+                }
128
+                .buttonStyle(.plain)
129
+            }
130
+        }
131
+        .animation(.easeInOut(duration: 0.22), value: isExpanded)
132
+    }
133
+
134
+    private var sectionTint: Color {
135
+        activeReason?.tint ?? .secondary
136
+    }
137
+
138
+    private var sectionSymbol: String {
139
+        activeReason?.symbol ?? "questionmark.circle.fill"
140
+    }
141
+}
+30 -0
USB Meter/Views/Sidebar/SidebarList/Sections/Help/Components/SidebarBluetoothStatusCardView.swift
@@ -0,0 +1,30 @@
1
+//
2
+//  SidebarBluetoothStatusCardView.swift
3
+//  USB Meter
4
+//
5
+
6
+import SwiftUI
7
+
8
+struct SidebarBluetoothStatusCardView: View {
9
+    let tint: Color
10
+    let statusText: String
11
+
12
+    var body: some View {
13
+        VStack(alignment: .leading, spacing: 6) {
14
+            HStack {
15
+                Label("Bluetooth", systemImage: "bolt.horizontal.circle.fill")
16
+                    .font(.footnote.weight(.semibold))
17
+                    .foregroundColor(tint)
18
+                Spacer()
19
+                Text(statusText)
20
+                    .font(.caption.weight(.semibold))
21
+                    .foregroundColor(.secondary)
22
+            }
23
+            Text("Refer to this adapter state while walking through the Bluetooth and Device troubleshooting steps.")
24
+                .font(.caption2)
25
+                .foregroundColor(.secondary)
26
+        }
27
+        .padding(14)
28
+        .meterCard(tint: tint, fillOpacity: 0.22, strokeOpacity: 0.26, cornerRadius: 18)
29
+    }
30
+}
+47 -0
USB Meter/Views/Sidebar/SidebarList/Sections/Help/Components/SidebarHelpNoticeCardView.swift
@@ -0,0 +1,47 @@
1
+//
2
+//  SidebarHelpNoticeCardView.swift
3
+//  USB Meter
4
+//
5
+
6
+import SwiftUI
7
+
8
+struct SidebarHelpNoticeCardView: View {
9
+    let reason: SidebarHelpReason
10
+    let cloudSyncHelpTitle: String
11
+    let cloudSyncHelpMessage: String
12
+
13
+    var body: some View {
14
+        VStack(alignment: .leading, spacing: 8) {
15
+            Text(helpNoticeTitle)
16
+                .font(.subheadline.weight(.semibold))
17
+            Text(helpNoticeDetail)
18
+                .font(.caption)
19
+                .foregroundColor(.secondary)
20
+        }
21
+        .frame(maxWidth: .infinity, alignment: .leading)
22
+        .padding(14)
23
+        .meterCard(tint: reason.tint, fillOpacity: 0.14, strokeOpacity: 0.20, cornerRadius: 18)
24
+    }
25
+
26
+    private var helpNoticeTitle: String {
27
+        switch reason {
28
+        case .bluetoothPermission:
29
+            return "Bluetooth access needs attention"
30
+        case .cloudSyncUnavailable:
31
+            return cloudSyncHelpTitle
32
+        case .noDevicesDetected:
33
+            return "No supported meters found yet"
34
+        }
35
+    }
36
+
37
+    private var helpNoticeDetail: String {
38
+        switch reason {
39
+        case .bluetoothPermission:
40
+            return "Open Bluetooth help to review the permission state and jump into Settings if access is blocked."
41
+        case .cloudSyncUnavailable:
42
+            return cloudSyncHelpMessage
43
+        case .noDevicesDetected:
44
+            return "Open Device help for quick checks like meter power, Bluetooth availability on the meter, or an existing connection to another phone."
45
+        }
46
+    }
47
+}
+45 -0
USB Meter/Views/Sidebar/SidebarList/Sections/Help/SidebarHelpReason.swift
@@ -0,0 +1,45 @@
1
+//
2
+//  SidebarHelpReason.swift
3
+//  USB Meter
4
+//
5
+
6
+import SwiftUI
7
+
8
+enum SidebarHelpReason: String {
9
+    case bluetoothPermission
10
+    case cloudSyncUnavailable
11
+    case noDevicesDetected
12
+
13
+    var tint: Color {
14
+        switch self {
15
+        case .bluetoothPermission:
16
+            return .orange
17
+        case .cloudSyncUnavailable:
18
+            return .indigo
19
+        case .noDevicesDetected:
20
+            return .yellow
21
+        }
22
+    }
23
+
24
+    var symbol: String {
25
+        switch self {
26
+        case .bluetoothPermission:
27
+            return "bolt.horizontal.circle.fill"
28
+        case .cloudSyncUnavailable:
29
+            return "icloud.slash.fill"
30
+        case .noDevicesDetected:
31
+            return "magnifyingglass.circle.fill"
32
+        }
33
+    }
34
+
35
+    var badgeTitle: String {
36
+        switch self {
37
+        case .bluetoothPermission:
38
+            return "Required"
39
+        case .cloudSyncUnavailable:
40
+            return "Sync Off"
41
+        case .noDevicesDetected:
42
+            return "Suggested"
43
+        }
44
+    }
45
+}
+22 -0
USB Meter/Views/Sidebar/SidebarList/Sections/SidebarDebugSectionView.swift
@@ -0,0 +1,22 @@
1
+//
2
+//  SidebarDebugSectionView.swift
3
+//  USB Meter
4
+//
5
+
6
+import SwiftUI
7
+
8
+struct SidebarDebugSectionView: View {
9
+    var body: some View {
10
+        Section(header: Text("Debug").font(.headline)) {
11
+            NavigationLink(destination: MeterMappingDebugView()) {
12
+                SidebarLinkCardView(
13
+                    title: "Meter Sync Debug",
14
+                    subtitle: "Inspect meter name sync data and iCloud KVS visibility as seen by this device.",
15
+                    symbol: "list.bullet.rectangle",
16
+                    tint: .purple
17
+                )
18
+            }
19
+            .buttonStyle(.plain)
20
+        }
21
+    }
22
+}
+141 -0
USB Meter/Views/Sidebar/SidebarList/Sections/SidebarHelpSectionView.swift
@@ -0,0 +1,141 @@
1
+//
2
+//  SidebarHelpSectionView.swift
3
+//  USB Meter
4
+//
5
+
6
+import SwiftUI
7
+
8
+struct SidebarHelpSectionView<BluetoothHelpDestination: View, DeviceHelpDestination: View>: View {
9
+    let activeReason: SidebarHelpReason?
10
+    let isExpanded: Bool
11
+    let bluetoothStatusTint: Color
12
+    let bluetoothStatusText: String
13
+    let cloudSyncHelpTitle: String
14
+    let cloudSyncHelpMessage: String
15
+    let onToggle: () -> Void
16
+    let onOpenSettings: () -> Void
17
+    let bluetoothHelpDestination: BluetoothHelpDestination
18
+    let deviceHelpDestination: DeviceHelpDestination
19
+
20
+    init(
21
+        activeReason: SidebarHelpReason?,
22
+        isExpanded: Bool,
23
+        bluetoothStatusTint: Color,
24
+        bluetoothStatusText: String,
25
+        cloudSyncHelpTitle: String,
26
+        cloudSyncHelpMessage: String,
27
+        onToggle: @escaping () -> Void,
28
+        onOpenSettings: @escaping () -> Void,
29
+        @ViewBuilder bluetoothHelpDestination: () -> BluetoothHelpDestination,
30
+        @ViewBuilder deviceHelpDestination: () -> DeviceHelpDestination
31
+    ) {
32
+        self.activeReason = activeReason
33
+        self.isExpanded = isExpanded
34
+        self.bluetoothStatusTint = bluetoothStatusTint
35
+        self.bluetoothStatusText = bluetoothStatusText
36
+        self.cloudSyncHelpTitle = cloudSyncHelpTitle
37
+        self.cloudSyncHelpMessage = cloudSyncHelpMessage
38
+        self.onToggle = onToggle
39
+        self.onOpenSettings = onOpenSettings
40
+        self.bluetoothHelpDestination = bluetoothHelpDestination()
41
+        self.deviceHelpDestination = deviceHelpDestination()
42
+    }
43
+
44
+    var body: some View {
45
+        Section(header: Text("Help & Troubleshooting").font(.headline)) {
46
+            Button(action: onToggle) {
47
+                HStack(spacing: 14) {
48
+                    Image(systemName: sectionSymbol)
49
+                        .font(.system(size: 18, weight: .semibold))
50
+                        .foregroundColor(sectionTint)
51
+                        .frame(width: 42, height: 42)
52
+                        .background(Circle().fill(sectionTint.opacity(0.18)))
53
+
54
+                    Text("Help")
55
+                        .font(.headline)
56
+
57
+                    Spacer()
58
+
59
+                    if let activeReason {
60
+                        Text(activeReason.badgeTitle)
61
+                            .font(.caption2.weight(.bold))
62
+                            .foregroundColor(activeReason.tint)
63
+                            .padding(.horizontal, 10)
64
+                            .padding(.vertical, 6)
65
+                            .background(
66
+                                Capsule(style: .continuous)
67
+                                    .fill(activeReason.tint.opacity(0.12))
68
+                            )
69
+                            .overlay(
70
+                                Capsule(style: .continuous)
71
+                                    .stroke(activeReason.tint.opacity(0.22), lineWidth: 1)
72
+                            )
73
+                    }
74
+
75
+                    Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
76
+                        .font(.footnote.weight(.bold))
77
+                        .foregroundColor(.secondary)
78
+                }
79
+                .padding(14)
80
+                .meterCard(tint: sectionTint, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 18)
81
+            }
82
+            .buttonStyle(.plain)
83
+
84
+            if isExpanded {
85
+                if let activeReason {
86
+                    SidebarHelpNoticeCardView(
87
+                        reason: activeReason,
88
+                        cloudSyncHelpTitle: cloudSyncHelpTitle,
89
+                        cloudSyncHelpMessage: cloudSyncHelpMessage
90
+                    )
91
+                }
92
+
93
+                SidebarBluetoothStatusCardView(
94
+                    tint: bluetoothStatusTint,
95
+                    statusText: bluetoothStatusText
96
+                )
97
+
98
+                if activeReason == .cloudSyncUnavailable {
99
+                    Button(action: onOpenSettings) {
100
+                        SidebarLinkCardView(
101
+                            title: "Open Settings",
102
+                            subtitle: "Check Apple ID, iCloud Drive, and any restrictions affecting Cloud sync.",
103
+                            symbol: "gearshape.fill",
104
+                            tint: .indigo
105
+                        )
106
+                    }
107
+                    .buttonStyle(.plain)
108
+                }
109
+
110
+                NavigationLink(destination: bluetoothHelpDestination) {
111
+                    SidebarLinkCardView(
112
+                        title: "Bluetooth",
113
+                        subtitle: "Permissions, adapter state, and connection tips.",
114
+                        symbol: "bolt.horizontal.circle.fill",
115
+                        tint: bluetoothStatusTint
116
+                    )
117
+                }
118
+                .buttonStyle(.plain)
119
+
120
+                NavigationLink(destination: deviceHelpDestination) {
121
+                    SidebarLinkCardView(
122
+                        title: "Device",
123
+                        subtitle: "Quick checks when a meter is not responding as expected.",
124
+                        symbol: "questionmark.circle.fill",
125
+                        tint: .orange
126
+                    )
127
+                }
128
+                .buttonStyle(.plain)
129
+            }
130
+        }
131
+        .animation(.easeInOut(duration: 0.22), value: isExpanded)
132
+    }
133
+
134
+    private var sectionTint: Color {
135
+        activeReason?.tint ?? .secondary
136
+    }
137
+
138
+    private var sectionSymbol: String {
139
+        activeReason?.symbol ?? "questionmark.circle.fill"
140
+    }
141
+}
+81 -0
USB Meter/Views/Sidebar/SidebarList/Sections/SidebarUSBMetersSectionView.swift
@@ -0,0 +1,81 @@
1
+//
2
+//  SidebarUSBMetersSectionView.swift
3
+//  USB Meter
4
+//
5
+
6
+import SwiftUI
7
+import CoreBluetooth
8
+
9
+struct SidebarUSBMetersSectionView: View {
10
+    let meters: [AppData.MeterSummary]
11
+    let managerState: CBManagerState
12
+    let hasLiveMeters: Bool
13
+    let scanStartedAt: Date?
14
+    let now: Date
15
+    let noDevicesHelpDelay: TimeInterval
16
+
17
+    var body: some View {
18
+        Section(header: usbSectionHeader) {
19
+            if meters.isEmpty {
20
+                Text(devicesEmptyStateText)
21
+                    .font(.footnote)
22
+                    .foregroundColor(.secondary)
23
+                    .frame(maxWidth: .infinity, alignment: .leading)
24
+                    .padding(18)
25
+                    .meterCard(
26
+                        tint: isWaitingForFirstDiscovery ? .blue : .secondary,
27
+                        fillOpacity: 0.14,
28
+                        strokeOpacity: 0.20
29
+                    )
30
+            } else {
31
+                ForEach(meters) { meterSummary in
32
+                    if let meter = meterSummary.meter {
33
+                        NavigationLink(destination: MeterView().environmentObject(meter)) {
34
+                            MeterRowView()
35
+                                .environmentObject(meter)
36
+                        }
37
+                        .buttonStyle(.plain)
38
+                    } else {
39
+                        NavigationLink(destination: MeterDetailView(meterSummary: meterSummary)) {
40
+                            MeterCardView(meterSummary: meterSummary)
41
+                        }
42
+                        .buttonStyle(.plain)
43
+                    }
44
+                }
45
+            }
46
+        }
47
+    }
48
+
49
+    private var isWaitingForFirstDiscovery: Bool {
50
+        guard managerState == .poweredOn else {
51
+            return false
52
+        }
53
+        guard hasLiveMeters == false else {
54
+            return false
55
+        }
56
+        guard let scanStartedAt else {
57
+            return false
58
+        }
59
+        return now.timeIntervalSince(scanStartedAt) < noDevicesHelpDelay
60
+    }
61
+
62
+    private var devicesEmptyStateText: String {
63
+        if isWaitingForFirstDiscovery {
64
+            return "Scanning for nearby supported meters..."
65
+        }
66
+        return "No meters yet. Nearby supported meters will appear here and remain available after they disappear."
67
+    }
68
+
69
+    private var usbSectionHeader: some View {
70
+        HStack {
71
+            Text("USB Meters")
72
+                .font(.headline)
73
+            Spacer()
74
+            Text("\(meters.count)")
75
+                .font(.caption.weight(.bold))
76
+                .padding(.horizontal, 10)
77
+                .padding(.vertical, 6)
78
+                .meterCard(tint: .blue, fillOpacity: 0.18, strokeOpacity: 0.24, cornerRadius: 999)
79
+        }
80
+    }
81
+}
+53 -0
USB Meter/Views/Sidebar/SidebarList/SidebarListView.swift
@@ -0,0 +1,53 @@
1
+//
2
+//  SidebarListView.swift
3
+//  USB Meter
4
+//
5
+
6
+import SwiftUI
7
+
8
+struct SidebarListView<USBMetersSection: View, HelpSection: View, DebugSection: View>: View {
9
+    let backgroundTint: Color
10
+    let usbMetersSection: USBMetersSection
11
+    let helpSection: HelpSection
12
+    let debugSection: DebugSection
13
+
14
+    init(
15
+        backgroundTint: Color,
16
+        @ViewBuilder usbMetersSection: () -> USBMetersSection,
17
+        @ViewBuilder helpSection: () -> HelpSection,
18
+        @ViewBuilder debugSection: () -> DebugSection
19
+    ) {
20
+        self.backgroundTint = backgroundTint
21
+        self.usbMetersSection = usbMetersSection()
22
+        self.helpSection = helpSection()
23
+        self.debugSection = debugSection()
24
+    }
25
+
26
+    var body: some View {
27
+        if #available(iOS 16.0, *) {
28
+            listBody.scrollContentBackground(.hidden)
29
+        } else {
30
+            listBody
31
+        }
32
+    }
33
+
34
+    private var listBody: some View {
35
+        List {
36
+            usbMetersSection
37
+            helpSection
38
+            debugSection
39
+        }
40
+        .listStyle(SidebarListStyle())
41
+        .background(
42
+            LinearGradient(
43
+                colors: [
44
+                    backgroundTint.opacity(0.18),
45
+                    Color.clear
46
+                ],
47
+                startPoint: .topLeading,
48
+                endPoint: .bottomTrailing
49
+            )
50
+            .ignoresSafeArea()
51
+        )
52
+    }
53
+}
+129 -0
USB Meter/Views/Sidebar/SidebarView.swift
@@ -0,0 +1,129 @@
1
+//
2
+//  SidebarView.swift
3
+//  USB Meter
4
+//
5
+
6
+import SwiftUI
7
+import Combine
8
+
9
+struct SidebarView: View {
10
+    @EnvironmentObject private var appData: AppData
11
+    @State private var isHelpExpanded = false
12
+    @State private var dismissedAutoHelpReason: SidebarHelpReason?
13
+    @State private var now = Date()
14
+    private let helpRefreshTimer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
15
+    private let noDevicesHelpDelay: TimeInterval = 12
16
+
17
+    var body: some View {
18
+        SidebarListView(backgroundTint: appData.bluetoothManager.managerState.color) {
19
+            usbMetersSection
20
+        } helpSection: {
21
+            helpSection
22
+        } debugSection: {
23
+            debugSection
24
+        }
25
+        .onAppear {
26
+            appData.bluetoothManager.start()
27
+            now = Date()
28
+        }
29
+        .onReceive(helpRefreshTimer) { currentDate in
30
+            now = currentDate
31
+        }
32
+        .onChange(of: activeHelpAutoReason) { newReason in
33
+            if newReason == nil {
34
+                dismissedAutoHelpReason = nil
35
+            }
36
+        }
37
+    }
38
+
39
+    private var usbMetersSection: some View {
40
+        SidebarUSBMetersSectionView(
41
+            meters: appData.meterSummaries,
42
+            managerState: appData.bluetoothManager.managerState,
43
+            hasLiveMeters: appData.meters.isEmpty == false,
44
+            scanStartedAt: appData.bluetoothManager.scanStartedAt,
45
+            now: now,
46
+            noDevicesHelpDelay: noDevicesHelpDelay
47
+        )
48
+    }
49
+
50
+    private var helpSection: some View {
51
+        SidebarHelpSectionView(
52
+            activeReason: activeHelpAutoReason,
53
+            isExpanded: helpIsExpanded,
54
+            bluetoothStatusTint: appData.bluetoothManager.managerState.color,
55
+            bluetoothStatusText: bluetoothStatusText,
56
+            cloudSyncHelpTitle: appData.cloudAvailability.helpTitle,
57
+            cloudSyncHelpMessage: appData.cloudAvailability.helpMessage,
58
+            onToggle: toggleHelpSection,
59
+            onOpenSettings: openSettings
60
+        ) {
61
+            appData.bluetoothManager.managerState.helpView
62
+        } deviceHelpDestination: {
63
+            DeviceHelpView()
64
+        }
65
+    }
66
+
67
+    private var debugSection: some View {
68
+        SidebarDebugSectionView()
69
+    }
70
+
71
+    private var bluetoothStatusText: String {
72
+        switch appData.bluetoothManager.managerState {
73
+        case .poweredOff:
74
+            return "Off"
75
+        case .poweredOn:
76
+            return "On"
77
+        case .resetting:
78
+            return "Resetting"
79
+        case .unauthorized:
80
+            return "Unauthorized"
81
+        case .unknown:
82
+            return "Unknown"
83
+        case .unsupported:
84
+            return "Unsupported"
85
+        @unknown default:
86
+            return "Other"
87
+        }
88
+    }
89
+
90
+    private var helpIsExpanded: Bool {
91
+        isHelpExpanded || shouldAutoExpandHelp
92
+    }
93
+
94
+    private var shouldAutoExpandHelp: Bool {
95
+        guard let activeHelpAutoReason else {
96
+            return false
97
+        }
98
+        return dismissedAutoHelpReason != activeHelpAutoReason
99
+    }
100
+
101
+    private var activeHelpAutoReason: SidebarHelpReason? {
102
+        SidebarAutoHelpResolver.activeReason(
103
+            managerState: appData.bluetoothManager.managerState,
104
+            cloudAvailability: appData.cloudAvailability,
105
+            hasLiveMeters: appData.meters.isEmpty == false,
106
+            scanStartedAt: appData.bluetoothManager.scanStartedAt,
107
+            now: now,
108
+            noDevicesHelpDelay: noDevicesHelpDelay
109
+        )
110
+    }
111
+
112
+    private func toggleHelpSection() {
113
+        withAnimation(.easeInOut(duration: 0.22)) {
114
+            if shouldAutoExpandHelp {
115
+                dismissedAutoHelpReason = activeHelpAutoReason
116
+                isHelpExpanded = false
117
+            } else {
118
+                isHelpExpanded.toggle()
119
+            }
120
+        }
121
+    }
122
+
123
+    private func openSettings() {
124
+        guard let settingsURL = URL(string: UIApplication.openSettingsURLString) else {
125
+            return
126
+        }
127
+        UIApplication.shared.open(settingsURL, options: [:], completionHandler: nil)
128
+    }
129
+}