USB-Meter / USB Meter / Views / ContentView.swift
Newer Older
449 lines | 15.821kb
Bogdan Timofte authored 2 weeks ago
1
//
2
//  ContentView.swift
3
//  USB Meter
4
//
5
//  Created by Bogdan Timofte on 01/03/2020.
6
//  Copyright © 2020 Bogdan Timofte. All rights reserved.
7
//
8

            
9
//MARK: Bluetooth Icon: https://upload.wikimedia.org/wikipedia/commons/d/da/Bluetooth.svg
10

            
11
import SwiftUI
Bogdan Timofte authored 2 weeks ago
12
import Combine
Bogdan Timofte authored 2 weeks ago
13

            
14
struct ContentView: View {
Bogdan Timofte authored 2 weeks ago
15
    private enum HelpAutoReason: String {
16
        case bluetoothPermission
Bogdan Timofte authored a week ago
17
        case cloudSyncUnavailable
Bogdan Timofte authored 2 weeks ago
18
        case noDevicesDetected
19

            
20
        var tint: Color {
21
            switch self {
22
            case .bluetoothPermission:
23
                return .orange
Bogdan Timofte authored a week ago
24
            case .cloudSyncUnavailable:
25
                return .indigo
Bogdan Timofte authored 2 weeks ago
26
            case .noDevicesDetected:
27
                return .yellow
28
            }
29
        }
30

            
31
        var symbol: String {
32
            switch self {
33
            case .bluetoothPermission:
34
                return "bolt.horizontal.circle.fill"
Bogdan Timofte authored a week ago
35
            case .cloudSyncUnavailable:
36
                return "icloud.slash.fill"
Bogdan Timofte authored 2 weeks ago
37
            case .noDevicesDetected:
38
                return "magnifyingglass.circle.fill"
39
            }
40
        }
41

            
42
        var badgeTitle: String {
43
            switch self {
44
            case .bluetoothPermission:
45
                return "Required"
Bogdan Timofte authored a week ago
46
            case .cloudSyncUnavailable:
47
                return "Sync Off"
Bogdan Timofte authored 2 weeks ago
48
            case .noDevicesDetected:
49
                return "Suggested"
50
            }
51
        }
52
    }
Bogdan Timofte authored a week ago
53

            
Bogdan Timofte authored 2 weeks ago
54
    @EnvironmentObject private var appData: AppData
Bogdan Timofte authored 2 weeks ago
55
    @State private var isHelpExpanded = false
56
    @State private var dismissedAutoHelpReason: HelpAutoReason?
57
    @State private var now = Date()
58
    private let helpRefreshTimer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
59
    private let noDevicesHelpDelay: TimeInterval = 12
Bogdan Timofte authored a week ago
60

            
Bogdan Timofte authored 2 weeks ago
61
    var body: some View {
62
        NavigationView {
Bogdan Timofte authored 2 weeks ago
63
            ScrollView {
64
                VStack(alignment: .leading, spacing: 18) {
65
                    headerCard
66
                    helpSection
Bogdan Timofte authored a week ago
67
                    debugLink
Bogdan Timofte authored 2 weeks ago
68
                    devicesSection
Bogdan Timofte authored 2 weeks ago
69
                }
Bogdan Timofte authored 2 weeks ago
70
                .padding()
Bogdan Timofte authored 2 weeks ago
71
            }
Bogdan Timofte authored 2 weeks ago
72
            .background(
73
                LinearGradient(
74
                    colors: [
75
                        appData.bluetoothManager.managerState.color.opacity(0.18),
76
                        Color.clear
77
                    ],
78
                    startPoint: .topLeading,
79
                    endPoint: .bottomTrailing
80
                )
81
                .ignoresSafeArea()
82
            )
Bogdan Timofte authored 2 weeks ago
83
            .navigationBarTitle(Text("USB Meters"), displayMode: .inline)
84
        }
Bogdan Timofte authored 2 weeks ago
85
        .onAppear {
86
            appData.bluetoothManager.start()
Bogdan Timofte authored 2 weeks ago
87
            now = Date()
88
        }
89
        .onReceive(helpRefreshTimer) { currentDate in
90
            now = currentDate
91
        }
92
        .onChange(of: activeHelpAutoReason) { newReason in
93
            if newReason == nil {
94
                dismissedAutoHelpReason = nil
95
            }
Bogdan Timofte authored 2 weeks ago
96
        }
Bogdan Timofte authored 2 weeks ago
97
    }
Bogdan Timofte authored 2 weeks ago
98

            
99
    private var headerCard: some View {
100
        VStack(alignment: .leading, spacing: 10) {
101
            Text("USB Meters")
102
                .font(.system(.title2, design: .rounded).weight(.bold))
103
            Text("Browse nearby supported meters and jump into live diagnostics, charge records, and device controls.")
104
                .font(.footnote)
105
                .foregroundColor(.secondary)
106
            HStack {
107
                Label("Bluetooth", systemImage: "bolt.horizontal.circle.fill")
108
                    .font(.footnote.weight(.semibold))
109
                    .foregroundColor(appData.bluetoothManager.managerState.color)
110
                Spacer()
111
                Text(bluetoothStatusText)
112
                    .font(.caption.weight(.semibold))
113
                    .foregroundColor(.secondary)
114
            }
115
        }
116
        .padding(18)
117
        .meterCard(tint: appData.bluetoothManager.managerState.color, fillOpacity: 0.22, strokeOpacity: 0.26)
118
    }
119

            
120
    private var helpSection: some View {
121
        VStack(alignment: .leading, spacing: 12) {
Bogdan Timofte authored 2 weeks ago
122
            Button(action: toggleHelpSection) {
123
                HStack(spacing: 14) {
124
                    Image(systemName: helpSectionSymbol)
125
                        .font(.system(size: 18, weight: .semibold))
126
                        .foregroundColor(helpSectionTint)
127
                        .frame(width: 42, height: 42)
128
                        .background(Circle().fill(helpSectionTint.opacity(0.18)))
129

            
130
                    VStack(alignment: .leading, spacing: 4) {
131
                        Text("Help")
132
                            .font(.headline)
133
                        Text(helpSectionSummary)
134
                            .font(.caption)
135
                            .foregroundColor(.secondary)
136
                    }
137

            
138
                    Spacer()
139

            
140
                    if let activeHelpAutoReason {
141
                        Text(activeHelpAutoReason.badgeTitle)
142
                            .font(.caption2.weight(.bold))
143
                            .foregroundColor(activeHelpAutoReason.tint)
144
                            .padding(.horizontal, 10)
145
                            .padding(.vertical, 6)
146
                            .background(
147
                                Capsule(style: .continuous)
148
                                    .fill(activeHelpAutoReason.tint.opacity(0.12))
149
                            )
150
                            .overlay(
151
                                Capsule(style: .continuous)
152
                                    .stroke(activeHelpAutoReason.tint.opacity(0.22), lineWidth: 1)
153
                            )
154
                    }
155

            
156
                    Image(systemName: helpIsExpanded ? "chevron.up" : "chevron.down")
157
                        .font(.footnote.weight(.bold))
158
                        .foregroundColor(.secondary)
159
                }
160
                .padding(14)
161
                .meterCard(tint: helpSectionTint, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 18)
Bogdan Timofte authored 2 weeks ago
162
            }
163
            .buttonStyle(.plain)
164

            
Bogdan Timofte authored 2 weeks ago
165
            if helpIsExpanded {
166
                if let activeHelpAutoReason {
167
                    helpNoticeCard(for: activeHelpAutoReason)
168
                }
169

            
Bogdan Timofte authored a week ago
170
                if activeHelpAutoReason == .cloudSyncUnavailable {
171
                    Button(action: openSettings) {
172
                        sidebarLinkCard(
173
                            title: "Open Settings",
174
                            subtitle: "Check Apple ID, iCloud Drive, and any restrictions affecting Cloud sync.",
175
                            symbol: "gearshape.fill",
176
                            tint: .indigo
177
                        )
178
                    }
179
                    .buttonStyle(.plain)
180
                }
181

            
Bogdan Timofte authored 2 weeks ago
182
                NavigationLink(destination: appData.bluetoothManager.managerState.helpView) {
183
                    sidebarLinkCard(
184
                        title: "Bluetooth",
185
                        subtitle: "Permissions, adapter state, and connection tips.",
186
                        symbol: "bolt.horizontal.circle.fill",
187
                        tint: appData.bluetoothManager.managerState.color
188
                    )
189
                }
190
                .buttonStyle(.plain)
191

            
192
                NavigationLink(destination: DeviceHelpView()) {
193
                    sidebarLinkCard(
194
                        title: "Device",
195
                        subtitle: "Quick checks when a meter is not responding as expected.",
196
                        symbol: "questionmark.circle.fill",
197
                        tint: .orange
198
                    )
199
                }
200
                .buttonStyle(.plain)
Bogdan Timofte authored 2 weeks ago
201
            }
202
        }
Bogdan Timofte authored 2 weeks ago
203
        .animation(.easeInOut(duration: 0.22), value: helpIsExpanded)
Bogdan Timofte authored 2 weeks ago
204
    }
205

            
206
    private var devicesSection: some View {
207
        VStack(alignment: .leading, spacing: 12) {
208
            HStack {
209
                Text("Discovered Devices")
210
                    .font(.headline)
211
                Spacer()
212
                Text("\(appData.meters.count)")
213
                    .font(.caption.weight(.bold))
214
                    .padding(.horizontal, 10)
215
                    .padding(.vertical, 6)
216
                    .meterCard(tint: .blue, fillOpacity: 0.18, strokeOpacity: 0.24, cornerRadius: 999)
217
            }
218

            
219
            if appData.meters.isEmpty {
Bogdan Timofte authored 2 weeks ago
220
                Text(devicesEmptyStateText)
Bogdan Timofte authored 2 weeks ago
221
                    .font(.footnote)
222
                    .foregroundColor(.secondary)
223
                    .frame(maxWidth: .infinity, alignment: .leading)
224
                    .padding(18)
Bogdan Timofte authored 2 weeks ago
225
                    .meterCard(
226
                        tint: isWaitingForFirstDiscovery ? .blue : .secondary,
227
                        fillOpacity: 0.14,
228
                        strokeOpacity: 0.20
229
                    )
Bogdan Timofte authored 2 weeks ago
230
            } else {
231
                ForEach(discoveredMeters, id: \.self) { meter in
232
                    NavigationLink(destination: MeterView().environmentObject(meter)) {
233
                        MeterRowView()
234
                            .environmentObject(meter)
235
                    }
236
                    .buttonStyle(.plain)
237
                }
238
            }
239
        }
240
    }
241

            
Bogdan Timofte authored a week ago
242
    private var debugLink: some View {
243
        NavigationLink(destination: MeterMappingDebugView()) {
244
            sidebarLinkCard(
245
                title: "Meter Mapping Debug",
246
                subtitle: "Inspect the MAC ↔ name/TC66 table as seen by this device.",
247
                symbol: "list.bullet.rectangle",
248
                tint: .purple
249
            )
250
        }
251
        .buttonStyle(.plain)
252
    }
253

            
Bogdan Timofte authored 2 weeks ago
254
    private var discoveredMeters: [Meter] {
255
        Array(appData.meters.values).sorted { lhs, rhs in
256
            lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
257
        }
258
    }
259

            
260
    private var bluetoothStatusText: String {
261
        switch appData.bluetoothManager.managerState {
262
        case .poweredOff:
263
            return "Off"
264
        case .poweredOn:
265
            return "On"
266
        case .resetting:
267
            return "Resetting"
268
        case .unauthorized:
269
            return "Unauthorized"
270
        case .unknown:
271
            return "Unknown"
272
        case .unsupported:
273
            return "Unsupported"
274
        @unknown default:
275
            return "Other"
276
        }
277
    }
278

            
Bogdan Timofte authored 2 weeks ago
279
    private var helpIsExpanded: Bool {
280
        isHelpExpanded || shouldAutoExpandHelp
281
    }
282

            
283
    private var shouldAutoExpandHelp: Bool {
284
        guard let activeHelpAutoReason else {
285
            return false
286
        }
287
        return dismissedAutoHelpReason != activeHelpAutoReason
288
    }
289

            
290
    private var activeHelpAutoReason: HelpAutoReason? {
291
        if appData.bluetoothManager.managerState == .unauthorized {
292
            return .bluetoothPermission
293
        }
Bogdan Timofte authored a week ago
294
        if shouldPromptForCloudSync {
295
            return .cloudSyncUnavailable
296
        }
Bogdan Timofte authored 2 weeks ago
297
        if hasWaitedLongEnoughForDevices {
298
            return .noDevicesDetected
299
        }
300
        return nil
301
    }
302

            
Bogdan Timofte authored a week ago
303
    private var shouldPromptForCloudSync: Bool {
304
        switch appData.cloudAvailability {
305
        case .noAccount, .error:
306
            return true
307
        case .unknown, .available:
308
            return false
309
        }
310
    }
311

            
Bogdan Timofte authored 2 weeks ago
312
    private var hasWaitedLongEnoughForDevices: Bool {
313
        guard appData.bluetoothManager.managerState == .poweredOn else {
314
            return false
315
        }
316
        guard appData.meters.isEmpty else {
317
            return false
318
        }
319
        guard let scanStartedAt = appData.bluetoothManager.scanStartedAt else {
320
            return false
321
        }
322
        return now.timeIntervalSince(scanStartedAt) >= noDevicesHelpDelay
323
    }
324

            
325
    private var isWaitingForFirstDiscovery: Bool {
326
        guard appData.bluetoothManager.managerState == .poweredOn else {
327
            return false
328
        }
329
        guard appData.meters.isEmpty else {
330
            return false
331
        }
332
        guard let scanStartedAt = appData.bluetoothManager.scanStartedAt else {
333
            return false
334
        }
335
        return now.timeIntervalSince(scanStartedAt) < noDevicesHelpDelay
336
    }
337

            
338
    private var devicesEmptyStateText: String {
339
        if isWaitingForFirstDiscovery {
340
            return "Scanning for nearby supported meters..."
341
        }
342
        return "No supported meters are visible right now."
343
    }
344

            
345
    private var helpSectionTint: Color {
346
        activeHelpAutoReason?.tint ?? .secondary
347
    }
348

            
349
    private var helpSectionSymbol: String {
350
        activeHelpAutoReason?.symbol ?? "questionmark.circle.fill"
351
    }
352

            
353
    private var helpSectionSummary: String {
354
        switch activeHelpAutoReason {
355
        case .bluetoothPermission:
356
            return "Bluetooth permission is needed before scanning can begin."
Bogdan Timofte authored a week ago
357
        case .cloudSyncUnavailable:
358
            return appData.cloudAvailability.helpMessage
Bogdan Timofte authored 2 weeks ago
359
        case .noDevicesDetected:
360
            return "No supported devices were found after \(Int(noDevicesHelpDelay)) seconds."
361
        case nil:
362
            return "Connection tips and quick checks when discovery needs help."
363
        }
364
    }
365

            
366
    private func toggleHelpSection() {
367
        withAnimation(.easeInOut(duration: 0.22)) {
368
            if shouldAutoExpandHelp {
369
                dismissedAutoHelpReason = activeHelpAutoReason
370
                isHelpExpanded = false
371
            } else {
372
                isHelpExpanded.toggle()
373
            }
374
        }
375
    }
376

            
Bogdan Timofte authored a week ago
377
    private func openSettings() {
378
        guard let settingsURL = URL(string: UIApplication.openSettingsURLString) else {
379
            return
380
        }
381
        UIApplication.shared.open(settingsURL, options: [:], completionHandler: nil)
382
    }
383

            
Bogdan Timofte authored 2 weeks ago
384
    private func helpNoticeCard(for reason: HelpAutoReason) -> some View {
385
        VStack(alignment: .leading, spacing: 8) {
386
            Text(helpNoticeTitle(for: reason))
387
                .font(.subheadline.weight(.semibold))
388
            Text(helpNoticeDetail(for: reason))
389
                .font(.caption)
390
                .foregroundColor(.secondary)
391
        }
392
        .frame(maxWidth: .infinity, alignment: .leading)
393
        .padding(14)
394
        .meterCard(tint: reason.tint, fillOpacity: 0.14, strokeOpacity: 0.20, cornerRadius: 18)
395
    }
396

            
397
    private func helpNoticeTitle(for reason: HelpAutoReason) -> String {
398
        switch reason {
399
        case .bluetoothPermission:
400
            return "Bluetooth access needs attention"
Bogdan Timofte authored a week ago
401
        case .cloudSyncUnavailable:
402
            return appData.cloudAvailability.helpTitle
Bogdan Timofte authored 2 weeks ago
403
        case .noDevicesDetected:
404
            return "No supported meters found yet"
405
        }
406
    }
407

            
408
    private func helpNoticeDetail(for reason: HelpAutoReason) -> String {
409
        switch reason {
410
        case .bluetoothPermission:
411
            return "Open Bluetooth help to review the permission state and jump into Settings if access is blocked."
Bogdan Timofte authored a week ago
412
        case .cloudSyncUnavailable:
413
            return appData.cloudAvailability.helpMessage
Bogdan Timofte authored 2 weeks ago
414
        case .noDevicesDetected:
415
            return "Open Device help for quick checks like meter power, Bluetooth availability on the meter, or an existing connection to another phone."
416
        }
417
    }
418

            
Bogdan Timofte authored 2 weeks ago
419
    private func sidebarLinkCard(
420
        title: String,
421
        subtitle: String,
422
        symbol: String,
423
        tint: Color
424
    ) -> some View {
425
        HStack(spacing: 14) {
426
            Image(systemName: symbol)
427
                .font(.system(size: 18, weight: .semibold))
428
                .foregroundColor(tint)
429
                .frame(width: 42, height: 42)
430
                .background(Circle().fill(tint.opacity(0.18)))
431

            
432
            VStack(alignment: .leading, spacing: 4) {
433
                Text(title)
434
                    .font(.headline)
435
                Text(subtitle)
436
                    .font(.caption)
437
                    .foregroundColor(.secondary)
438
            }
439

            
440
            Spacer()
441

            
442
            Image(systemName: "chevron.right")
443
                .font(.footnote.weight(.bold))
444
                .foregroundColor(.secondary)
445
        }
446
        .padding(14)
447
        .meterCard(tint: tint, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 18)
448
    }
Bogdan Timofte authored 2 weeks ago
449
}