Showing 4 changed files with 334 additions and 80 deletions
+8 -0
USB Meter/Model/BluetoothManager.swift
@@ -12,6 +12,7 @@ class BluetoothManager : NSObject, ObservableObject {
12 12
     private var manager: CBCentralManager?
13 13
     private var advertisementDataCache = AdvertisementDataCache()
14 14
     @Published var managerState = CBManagerState.unknown
15
+    @Published private(set) var scanStartedAt: Date?
15 16
     
16 17
     override init () {
17 18
         super.init()
@@ -101,8 +102,10 @@ extension BluetoothManager : CBCentralManagerDelegate {
101 102
         
102 103
         switch central.state {
103 104
         case .poweredOff:
105
+            scanStartedAt = nil
104 106
             track("Bluetooth is Off. How should I behave?")
105 107
         case .poweredOn:
108
+            scanStartedAt = Date()
106 109
             track("Bluetooth is On... Start scanning...")
107 110
             // note that "didDisconnectPeripheral" won't be called if BLE is turned off while connected
108 111
             // connectedPeripheral = nil
@@ -111,14 +114,19 @@ extension BluetoothManager : CBCentralManagerDelegate {
111 114
                 self?.scanForMeters()
112 115
             }
113 116
         case .resetting:
117
+            scanStartedAt = nil
114 118
             track("Bluetooth is reseting... . Whatever that means.")
115 119
         case .unauthorized:
120
+            scanStartedAt = nil
116 121
             track("Bluetooth is not authorized.")
117 122
         case .unknown:
123
+            scanStartedAt = nil
118 124
             track("Bluetooth is in an unknown state.")
119 125
         case .unsupported:
126
+            scanStartedAt = nil
120 127
             track("Bluetooth not supported by device")
121 128
         default:
129
+            scanStartedAt = nil
122 130
             track("Bluetooth is in a state never seen before!")
123 131
         }
124 132
     }
+232 -20
USB Meter/Views/ContentView.swift
@@ -9,10 +9,47 @@
9 9
 //MARK: Bluetooth Icon: https://upload.wikimedia.org/wikipedia/commons/d/da/Bluetooth.svg
10 10
 
11 11
 import SwiftUI
12
+import Combine
12 13
 
13 14
 struct ContentView: View {
15
+    private enum HelpAutoReason: String {
16
+        case bluetoothPermission
17
+        case noDevicesDetected
18
+
19
+        var tint: Color {
20
+            switch self {
21
+            case .bluetoothPermission:
22
+                return .orange
23
+            case .noDevicesDetected:
24
+                return .yellow
25
+            }
26
+        }
27
+
28
+        var symbol: String {
29
+            switch self {
30
+            case .bluetoothPermission:
31
+                return "bolt.horizontal.circle.fill"
32
+            case .noDevicesDetected:
33
+                return "magnifyingglass.circle.fill"
34
+            }
35
+        }
36
+
37
+        var badgeTitle: String {
38
+            switch self {
39
+            case .bluetoothPermission:
40
+                return "Required"
41
+            case .noDevicesDetected:
42
+                return "Suggested"
43
+            }
44
+        }
45
+    }
14 46
     
15 47
     @EnvironmentObject private var appData: AppData
48
+    @State private var isHelpExpanded = false
49
+    @State private var dismissedAutoHelpReason: HelpAutoReason?
50
+    @State private var now = Date()
51
+    private let helpRefreshTimer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
52
+    private let noDevicesHelpDelay: TimeInterval = 12
16 53
     
17 54
     var body: some View {
18 55
         NavigationView {
@@ -39,6 +76,15 @@ struct ContentView: View {
39 76
         }
40 77
         .onAppear {
41 78
             appData.bluetoothManager.start()
79
+            now = Date()
80
+        }
81
+        .onReceive(helpRefreshTimer) { currentDate in
82
+            now = currentDate
83
+        }
84
+        .onChange(of: activeHelpAutoReason) { newReason in
85
+            if newReason == nil {
86
+                dismissedAutoHelpReason = nil
87
+            }
42 88
         }
43 89
     }
44 90
 
@@ -65,29 +111,76 @@ struct ContentView: View {
65 111
 
66 112
     private var helpSection: some View {
67 113
         VStack(alignment: .leading, spacing: 12) {
68
-            Text("Help")
69
-                .font(.headline)
70
-
71
-            NavigationLink(destination: appData.bluetoothManager.managerState.helpView) {
72
-                sidebarLinkCard(
73
-                    title: "Bluetooth",
74
-                    subtitle: "Permissions, adapter state, and connection tips.",
75
-                    symbol: "bolt.horizontal.circle.fill",
76
-                    tint: appData.bluetoothManager.managerState.color
77
-                )
114
+            Button(action: toggleHelpSection) {
115
+                HStack(spacing: 14) {
116
+                    Image(systemName: helpSectionSymbol)
117
+                        .font(.system(size: 18, weight: .semibold))
118
+                        .foregroundColor(helpSectionTint)
119
+                        .frame(width: 42, height: 42)
120
+                        .background(Circle().fill(helpSectionTint.opacity(0.18)))
121
+
122
+                    VStack(alignment: .leading, spacing: 4) {
123
+                        Text("Help")
124
+                            .font(.headline)
125
+                        Text(helpSectionSummary)
126
+                            .font(.caption)
127
+                            .foregroundColor(.secondary)
128
+                    }
129
+
130
+                    Spacer()
131
+
132
+                    if let activeHelpAutoReason {
133
+                        Text(activeHelpAutoReason.badgeTitle)
134
+                            .font(.caption2.weight(.bold))
135
+                            .foregroundColor(activeHelpAutoReason.tint)
136
+                            .padding(.horizontal, 10)
137
+                            .padding(.vertical, 6)
138
+                            .background(
139
+                                Capsule(style: .continuous)
140
+                                    .fill(activeHelpAutoReason.tint.opacity(0.12))
141
+                            )
142
+                            .overlay(
143
+                                Capsule(style: .continuous)
144
+                                    .stroke(activeHelpAutoReason.tint.opacity(0.22), lineWidth: 1)
145
+                            )
146
+                    }
147
+
148
+                    Image(systemName: helpIsExpanded ? "chevron.up" : "chevron.down")
149
+                        .font(.footnote.weight(.bold))
150
+                        .foregroundColor(.secondary)
151
+                }
152
+                .padding(14)
153
+                .meterCard(tint: helpSectionTint, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 18)
78 154
             }
79 155
             .buttonStyle(.plain)
80 156
 
81
-            NavigationLink(destination: DeviceHelpView()) {
82
-                sidebarLinkCard(
83
-                    title: "Device",
84
-                    subtitle: "Quick checks when a meter is not responding as expected.",
85
-                    symbol: "questionmark.circle.fill",
86
-                    tint: .orange
87
-                )
157
+            if helpIsExpanded {
158
+                if let activeHelpAutoReason {
159
+                    helpNoticeCard(for: activeHelpAutoReason)
160
+                }
161
+
162
+                NavigationLink(destination: appData.bluetoothManager.managerState.helpView) {
163
+                    sidebarLinkCard(
164
+                        title: "Bluetooth",
165
+                        subtitle: "Permissions, adapter state, and connection tips.",
166
+                        symbol: "bolt.horizontal.circle.fill",
167
+                        tint: appData.bluetoothManager.managerState.color
168
+                    )
169
+                }
170
+                .buttonStyle(.plain)
171
+
172
+                NavigationLink(destination: DeviceHelpView()) {
173
+                    sidebarLinkCard(
174
+                        title: "Device",
175
+                        subtitle: "Quick checks when a meter is not responding as expected.",
176
+                        symbol: "questionmark.circle.fill",
177
+                        tint: .orange
178
+                    )
179
+                }
180
+                .buttonStyle(.plain)
88 181
             }
89
-            .buttonStyle(.plain)
90 182
         }
183
+        .animation(.easeInOut(duration: 0.22), value: helpIsExpanded)
91 184
     }
92 185
 
93 186
     private var devicesSection: some View {
@@ -104,12 +197,16 @@ struct ContentView: View {
104 197
             }
105 198
 
106 199
             if appData.meters.isEmpty {
107
-                Text("No supported meters are visible right now.")
200
+                Text(devicesEmptyStateText)
108 201
                     .font(.footnote)
109 202
                     .foregroundColor(.secondary)
110 203
                     .frame(maxWidth: .infinity, alignment: .leading)
111 204
                     .padding(18)
112
-                    .meterCard(tint: .secondary, fillOpacity: 0.14, strokeOpacity: 0.20)
205
+                    .meterCard(
206
+                        tint: isWaitingForFirstDiscovery ? .blue : .secondary,
207
+                        fillOpacity: 0.14,
208
+                        strokeOpacity: 0.20
209
+                    )
113 210
             } else {
114 211
                 ForEach(discoveredMeters, id: \.self) { meter in
115 212
                     NavigationLink(destination: MeterView().environmentObject(meter)) {
@@ -147,6 +244,121 @@ struct ContentView: View {
147 244
         }
148 245
     }
149 246
 
247
+    private var helpIsExpanded: Bool {
248
+        isHelpExpanded || shouldAutoExpandHelp
249
+    }
250
+
251
+    private var shouldAutoExpandHelp: Bool {
252
+        guard let activeHelpAutoReason else {
253
+            return false
254
+        }
255
+        return dismissedAutoHelpReason != activeHelpAutoReason
256
+    }
257
+
258
+    private var activeHelpAutoReason: HelpAutoReason? {
259
+        if appData.bluetoothManager.managerState == .unauthorized {
260
+            return .bluetoothPermission
261
+        }
262
+        if hasWaitedLongEnoughForDevices {
263
+            return .noDevicesDetected
264
+        }
265
+        return nil
266
+    }
267
+
268
+    private var hasWaitedLongEnoughForDevices: Bool {
269
+        guard appData.bluetoothManager.managerState == .poweredOn else {
270
+            return false
271
+        }
272
+        guard appData.meters.isEmpty else {
273
+            return false
274
+        }
275
+        guard let scanStartedAt = appData.bluetoothManager.scanStartedAt else {
276
+            return false
277
+        }
278
+        return now.timeIntervalSince(scanStartedAt) >= noDevicesHelpDelay
279
+    }
280
+
281
+    private var isWaitingForFirstDiscovery: Bool {
282
+        guard appData.bluetoothManager.managerState == .poweredOn else {
283
+            return false
284
+        }
285
+        guard appData.meters.isEmpty else {
286
+            return false
287
+        }
288
+        guard let scanStartedAt = appData.bluetoothManager.scanStartedAt else {
289
+            return false
290
+        }
291
+        return now.timeIntervalSince(scanStartedAt) < noDevicesHelpDelay
292
+    }
293
+
294
+    private var devicesEmptyStateText: String {
295
+        if isWaitingForFirstDiscovery {
296
+            return "Scanning for nearby supported meters..."
297
+        }
298
+        return "No supported meters are visible right now."
299
+    }
300
+
301
+    private var helpSectionTint: Color {
302
+        activeHelpAutoReason?.tint ?? .secondary
303
+    }
304
+
305
+    private var helpSectionSymbol: String {
306
+        activeHelpAutoReason?.symbol ?? "questionmark.circle.fill"
307
+    }
308
+
309
+    private var helpSectionSummary: String {
310
+        switch activeHelpAutoReason {
311
+        case .bluetoothPermission:
312
+            return "Bluetooth permission is needed before scanning can begin."
313
+        case .noDevicesDetected:
314
+            return "No supported devices were found after \(Int(noDevicesHelpDelay)) seconds."
315
+        case nil:
316
+            return "Connection tips and quick checks when discovery needs help."
317
+        }
318
+    }
319
+
320
+    private func toggleHelpSection() {
321
+        withAnimation(.easeInOut(duration: 0.22)) {
322
+            if shouldAutoExpandHelp {
323
+                dismissedAutoHelpReason = activeHelpAutoReason
324
+                isHelpExpanded = false
325
+            } else {
326
+                isHelpExpanded.toggle()
327
+            }
328
+        }
329
+    }
330
+
331
+    private func helpNoticeCard(for reason: HelpAutoReason) -> some View {
332
+        VStack(alignment: .leading, spacing: 8) {
333
+            Text(helpNoticeTitle(for: reason))
334
+                .font(.subheadline.weight(.semibold))
335
+            Text(helpNoticeDetail(for: reason))
336
+                .font(.caption)
337
+                .foregroundColor(.secondary)
338
+        }
339
+        .frame(maxWidth: .infinity, alignment: .leading)
340
+        .padding(14)
341
+        .meterCard(tint: reason.tint, fillOpacity: 0.14, strokeOpacity: 0.20, cornerRadius: 18)
342
+    }
343
+
344
+    private func helpNoticeTitle(for reason: HelpAutoReason) -> String {
345
+        switch reason {
346
+        case .bluetoothPermission:
347
+            return "Bluetooth access needs attention"
348
+        case .noDevicesDetected:
349
+            return "No supported meters found yet"
350
+        }
351
+    }
352
+
353
+    private func helpNoticeDetail(for reason: HelpAutoReason) -> String {
354
+        switch reason {
355
+        case .bluetoothPermission:
356
+            return "Open Bluetooth help to review the permission state and jump into Settings if access is blocked."
357
+        case .noDevicesDetected:
358
+            return "Open Device help for quick checks like meter power, Bluetooth availability on the meter, or an existing connection to another phone."
359
+        }
360
+    }
361
+
150 362
     private func sidebarLinkCard(
151 363
         title: String,
152 364
         subtitle: String,
+0 -37
USB Meter/Views/Meter/MeterSettingsView.swift
@@ -33,27 +33,6 @@ struct MeterSettingsView: View {
33 33
                     }
34 34
                 }
35 35
 
36
-                if meter.operationalState == .dataIsAvailable {
37
-                    settingsCard(title: "Device Info", tint: .blue) {
38
-                        DeviceInfoRow(label: "Advertised Model", value: meter.modelString)
39
-                        DeviceInfoRow(label: "Displayed Model", value: meter.deviceModelSummary)
40
-                        DeviceInfoRow(label: "Working Voltage", value: meter.documentedWorkingVoltage)
41
-                        DeviceInfoRow(label: "Temperature Unit", value: meter.temperatureUnitDescription)
42
-                        if meter.modelNumber != 0 {
43
-                            DeviceInfoRow(label: "Model Code", value: "\(meter.modelNumber)")
44
-                        }
45
-                        if !meter.firmwareVersion.isEmpty {
46
-                            DeviceInfoRow(label: "Firmware", value: meter.firmwareVersion)
47
-                        }
48
-                        if meter.serialNumber != 0 {
49
-                            DeviceInfoRow(label: "Serial", value: "\(meter.serialNumber)")
50
-                        }
51
-                        if meter.bootCount != 0 {
52
-                            DeviceInfoRow(label: "Boot Count", value: "\(meter.bootCount)")
53
-                        }
54
-                    }
55
-                }
56
-
57 36
                 if meter.operationalState == .dataIsAvailable && meter.supportsManualTemperatureUnitSelection {
58 37
                     settingsCard(title: "Temperature Unit", tint: .orange) {
59 38
                         Text("TC66 reports temperature using the unit selected on the device. Keep this setting matched to the meter.")
@@ -127,22 +106,6 @@ struct MeterSettingsView: View {
127 106
     }
128 107
 }
129 108
 
130
-private struct DeviceInfoRow: View {
131
-    let label: String
132
-    let value: String
133
-
134
-    var body: some View {
135
-        HStack {
136
-            Text(label)
137
-            Spacer()
138
-            Text(value)
139
-                .foregroundColor(.secondary)
140
-                .multilineTextAlignment(.trailing)
141
-        }
142
-        .font(.footnote)
143
-    }
144
-}
145
-
146 109
 struct EditNameView: View {
147 110
     
148 111
     @EnvironmentObject private var meter: Meter
+94 -23
USB Meter/Views/Meter/MeterView.swift
@@ -70,6 +70,11 @@ struct MeterView: View {
70 70
                     .frame(width: 24)
71 71
                     .padding(.vertical)
72 72
             }
73
+            NavigationLink(destination: MeterInfoView().environmentObject(meter)) {
74
+                Image(systemName: "info.circle.fill")
75
+                    .padding(.vertical)
76
+                    .padding(.leading)
77
+            }
73 78
             NavigationLink(destination: MeterSettingsView().environmentObject(meter)) {
74 79
                 Image(systemName: "gearshape.fill")
75 80
                     .padding(.vertical)
@@ -102,13 +107,6 @@ struct MeterView: View {
102 107
                     }
103 108
                 }
104 109
             }
105
-
106
-            Divider().opacity(0.35)
107
-
108
-            HStack(spacing: 12) {
109
-                headerPill(title: "MAC", value: meter.btSerial.macAddress.description)
110
-                headerPill(title: "Range", value: meter.documentedWorkingVoltage)
111
-            }
112 110
         }
113 111
         .padding(20)
114 112
         .meterCard(tint: meter.color, fillOpacity: 0.22, strokeOpacity: 0.24)
@@ -208,22 +206,6 @@ struct MeterView: View {
208 206
         .buttonStyle(.plain)
209 207
     }
210 208
 
211
-    private func headerPill(title: String, value: String) -> some View {
212
-        VStack(alignment: .leading, spacing: 4) {
213
-            Text(title)
214
-                .font(.caption.weight(.semibold))
215
-                .foregroundColor(.secondary)
216
-            Text(value)
217
-                .font(.footnote.weight(.semibold))
218
-                .lineLimit(1)
219
-                .minimumScaleFactor(0.8)
220
-        }
221
-        .frame(maxWidth: .infinity, alignment: .leading)
222
-        .padding(.horizontal, 12)
223
-        .padding(.vertical, 10)
224
-        .meterCard(tint: meter.color, fillOpacity: 0.16, strokeOpacity: 0.20, cornerRadius: 16)
225
-    }
226
-
227 209
     private var statusText: String {
228 210
         switch meter.operationalState {
229 211
         case .notPresent:
@@ -247,3 +229,92 @@ struct MeterView: View {
247 229
         Meter.operationalColor(for: meter.operationalState)
248 230
     }
249 231
 }
232
+
233
+private struct MeterInfoView: View {
234
+    @EnvironmentObject private var meter: Meter
235
+
236
+    var body: some View {
237
+        ScrollView {
238
+            VStack(spacing: 14) {
239
+                MeterInfoCard(title: "Overview", tint: meter.color) {
240
+                    MeterInfoRow(label: "Name", value: meter.name)
241
+                    MeterInfoRow(label: "Displayed Model", value: meter.deviceModelSummary)
242
+                    MeterInfoRow(label: "Advertised Model", value: meter.modelString)
243
+                    MeterInfoRow(label: "Working Voltage", value: meter.documentedWorkingVoltage)
244
+                    MeterInfoRow(label: "Temperature Unit", value: meter.temperatureUnitDescription)
245
+                }
246
+
247
+                MeterInfoCard(title: "Identifiers", tint: .blue) {
248
+                    MeterInfoRow(label: "MAC", value: meter.btSerial.macAddress.description)
249
+                    if meter.modelNumber != 0 {
250
+                        MeterInfoRow(label: "Model Code", value: "\(meter.modelNumber)")
251
+                    }
252
+                }
253
+
254
+                if meter.operationalState == .dataIsAvailable {
255
+                    MeterInfoCard(title: "Live Device Details", tint: .indigo) {
256
+                        if !meter.firmwareVersion.isEmpty {
257
+                            MeterInfoRow(label: "Firmware", value: meter.firmwareVersion)
258
+                        }
259
+                        if meter.serialNumber != 0 {
260
+                            MeterInfoRow(label: "Serial", value: "\(meter.serialNumber)")
261
+                        }
262
+                        if meter.bootCount != 0 {
263
+                            MeterInfoRow(label: "Boot Count", value: "\(meter.bootCount)")
264
+                        }
265
+                    }
266
+                } else {
267
+                    MeterInfoCard(title: "Live Device Details", tint: .secondary) {
268
+                        Text("Connect to the meter to load firmware, serial, and boot details.")
269
+                            .font(.footnote)
270
+                            .foregroundColor(.secondary)
271
+                    }
272
+                }
273
+            }
274
+            .padding()
275
+        }
276
+        .background(
277
+            LinearGradient(
278
+                colors: [meter.color.opacity(0.14), Color.clear],
279
+                startPoint: .topLeading,
280
+                endPoint: .bottomTrailing
281
+            )
282
+            .ignoresSafeArea()
283
+        )
284
+        .navigationBarTitle("Meter Info")
285
+        .navigationBarItems(trailing: RSSIView(RSSI: meter.btSerial.RSSI).frame(width: 24))
286
+    }
287
+}
288
+
289
+private struct MeterInfoCard<Content: View>: View {
290
+    let title: String
291
+    let tint: Color
292
+    @ViewBuilder var content: Content
293
+
294
+    var body: some View {
295
+        VStack(alignment: .leading, spacing: 12) {
296
+            Text(title)
297
+                .font(.headline)
298
+            content
299
+        }
300
+        .frame(maxWidth: .infinity, alignment: .leading)
301
+        .padding(18)
302
+        .meterCard(tint: tint, fillOpacity: 0.18, strokeOpacity: 0.24)
303
+    }
304
+}
305
+
306
+private struct MeterInfoRow: View {
307
+    let label: String
308
+    let value: String
309
+
310
+    var body: some View {
311
+        HStack {
312
+            Text(label)
313
+            Spacer()
314
+            Text(value)
315
+                .foregroundColor(.secondary)
316
+                .multilineTextAlignment(.trailing)
317
+        }
318
+        .font(.footnote)
319
+    }
320
+}