USB-Meter / USB Meter / Views / ContentView.swift
Newer Older
392 lines | 13.768kb
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
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
    }
Bogdan Timofte authored 2 weeks ago
46

            
47
    @EnvironmentObject private var appData: AppData
Bogdan Timofte authored 2 weeks ago
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
Bogdan Timofte authored 2 weeks ago
53

            
54
    var body: some View {
55
        NavigationView {
Bogdan Timofte authored 2 weeks ago
56
            ScrollView {
57
                VStack(alignment: .leading, spacing: 18) {
58
                    headerCard
59
                    helpSection
60
                    devicesSection
Bogdan Timofte authored 2 weeks ago
61
                }
Bogdan Timofte authored 2 weeks ago
62
                .padding()
Bogdan Timofte authored 2 weeks ago
63
            }
Bogdan Timofte authored 2 weeks ago
64
            .background(
65
                LinearGradient(
66
                    colors: [
67
                        appData.bluetoothManager.managerState.color.opacity(0.18),
68
                        Color.clear
69
                    ],
70
                    startPoint: .topLeading,
71
                    endPoint: .bottomTrailing
72
                )
73
                .ignoresSafeArea()
74
            )
Bogdan Timofte authored 2 weeks ago
75
            .navigationBarTitle(Text("USB Meters"), displayMode: .inline)
76
        }
Bogdan Timofte authored 2 weeks ago
77
        .onAppear {
78
            appData.bluetoothManager.start()
Bogdan Timofte authored 2 weeks ago
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
            }
Bogdan Timofte authored 2 weeks ago
88
        }
Bogdan Timofte authored 2 weeks ago
89
    }
Bogdan Timofte authored 2 weeks ago
90

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

            
112
    private var helpSection: some View {
113
        VStack(alignment: .leading, spacing: 12) {
Bogdan Timofte authored 2 weeks ago
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)
Bogdan Timofte authored 2 weeks ago
154
            }
155
            .buttonStyle(.plain)
156

            
Bogdan Timofte authored 2 weeks ago
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)
Bogdan Timofte authored 2 weeks ago
181
            }
182
        }
Bogdan Timofte authored 2 weeks ago
183
        .animation(.easeInOut(duration: 0.22), value: helpIsExpanded)
Bogdan Timofte authored 2 weeks ago
184
    }
185

            
186
    private var devicesSection: some View {
187
        VStack(alignment: .leading, spacing: 12) {
188
            HStack {
189
                Text("Discovered Devices")
190
                    .font(.headline)
191
                Spacer()
192
                Text("\(appData.meters.count)")
193
                    .font(.caption.weight(.bold))
194
                    .padding(.horizontal, 10)
195
                    .padding(.vertical, 6)
196
                    .meterCard(tint: .blue, fillOpacity: 0.18, strokeOpacity: 0.24, cornerRadius: 999)
197
            }
198

            
199
            if appData.meters.isEmpty {
Bogdan Timofte authored 2 weeks ago
200
                Text(devicesEmptyStateText)
Bogdan Timofte authored 2 weeks ago
201
                    .font(.footnote)
202
                    .foregroundColor(.secondary)
203
                    .frame(maxWidth: .infinity, alignment: .leading)
204
                    .padding(18)
Bogdan Timofte authored 2 weeks ago
205
                    .meterCard(
206
                        tint: isWaitingForFirstDiscovery ? .blue : .secondary,
207
                        fillOpacity: 0.14,
208
                        strokeOpacity: 0.20
209
                    )
Bogdan Timofte authored 2 weeks ago
210
            } else {
211
                ForEach(discoveredMeters, id: \.self) { meter in
212
                    NavigationLink(destination: MeterView().environmentObject(meter)) {
213
                        MeterRowView()
214
                            .environmentObject(meter)
215
                    }
216
                    .buttonStyle(.plain)
217
                }
218
            }
219
        }
220
    }
221

            
222
    private var discoveredMeters: [Meter] {
223
        Array(appData.meters.values).sorted { lhs, rhs in
224
            lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
225
        }
226
    }
227

            
228
    private var bluetoothStatusText: String {
229
        switch appData.bluetoothManager.managerState {
230
        case .poweredOff:
231
            return "Off"
232
        case .poweredOn:
233
            return "On"
234
        case .resetting:
235
            return "Resetting"
236
        case .unauthorized:
237
            return "Unauthorized"
238
        case .unknown:
239
            return "Unknown"
240
        case .unsupported:
241
            return "Unsupported"
242
        @unknown default:
243
            return "Other"
244
        }
245
    }
246

            
Bogdan Timofte authored 2 weeks ago
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

            
Bogdan Timofte authored 2 weeks ago
362
    private func sidebarLinkCard(
363
        title: String,
364
        subtitle: String,
365
        symbol: String,
366
        tint: Color
367
    ) -> some View {
368
        HStack(spacing: 14) {
369
            Image(systemName: symbol)
370
                .font(.system(size: 18, weight: .semibold))
371
                .foregroundColor(tint)
372
                .frame(width: 42, height: 42)
373
                .background(Circle().fill(tint.opacity(0.18)))
374

            
375
            VStack(alignment: .leading, spacing: 4) {
376
                Text(title)
377
                    .font(.headline)
378
                Text(subtitle)
379
                    .font(.caption)
380
                    .foregroundColor(.secondary)
381
            }
382

            
383
            Spacer()
384

            
385
            Image(systemName: "chevron.right")
386
                .font(.footnote.weight(.bold))
387
                .foregroundColor(.secondary)
388
        }
389
        .padding(14)
390
        .meterCard(tint: tint, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 18)
391
    }
Bogdan Timofte authored 2 weeks ago
392
}