Newer Older
312 lines | 11.031kb
Bogdan Timofte authored 2 months ago
1
//
2
//  SidebarView.swift
3
//  USB Meter
4
//
5

            
6
import SwiftUI
7
import Combine
8

            
Bogdan Timofte authored a month ago
9
private enum SidebarCreationSheet: Identifiable {
10
    case meter
11
    case device
12
    case charger
Bogdan Timofte authored a month ago
13
    case powerbank
Bogdan Timofte authored a month ago
14

            
15
    var id: String {
16
        switch self {
17
        case .meter:
18
            return "meter"
19
        case .device:
20
            return "device"
21
        case .charger:
22
            return "charger"
Bogdan Timofte authored a month ago
23
        case .powerbank:
24
            return "powerbank"
Bogdan Timofte authored a month ago
25
        }
26
    }
27
}
28

            
Bogdan Timofte authored 2 months ago
29
struct SidebarView: View {
30
    @EnvironmentObject private var appData: AppData
Bogdan Timofte authored a month ago
31
    @State private var isUSBMetersExpanded = true
32
    @State private var isDevicesExpanded = true
33
    @State private var isChargersExpanded = true
Bogdan Timofte authored a month ago
34
    @State private var isPowerbanksExpanded = true
Bogdan Timofte authored 2 months ago
35
    @State private var isHelpExpanded = false
36
    @State private var dismissedAutoHelpReason: SidebarHelpReason?
37
    @State private var now = Date()
Bogdan Timofte authored a month ago
38
    @State private var creationSheet: SidebarCreationSheet?
Bogdan Timofte authored 2 months ago
39
    private let helpRefreshTimer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
40
    private let noDevicesHelpDelay: TimeInterval = 12
41

            
42
    var body: some View {
43
        SidebarListView(backgroundTint: appData.bluetoothManager.managerState.color) {
44
            usbMetersSection
45
        } helpSection: {
46
            helpSection
47
        } debugSection: {
48
            debugSection
49
        }
50
        .onAppear {
51
            appData.bluetoothManager.start()
52
            now = Date()
53
        }
54
        .onReceive(helpRefreshTimer) { currentDate in
55
            now = currentDate
56
        }
57
        .onChange(of: activeHelpAutoReason) { newReason in
58
            if newReason == nil {
59
                dismissedAutoHelpReason = nil
60
            }
61
        }
Bogdan Timofte authored a month ago
62
        .sheet(item: $creationSheet) { sheet in
63
            switch sheet {
64
            case .meter:
65
                MeterEditorSheetView()
66
                    .environmentObject(appData)
67
            case .device:
Bogdan Timofte authored a month ago
68
                ChargedDeviceEditorSheetView()
Bogdan Timofte authored a month ago
69
                .environmentObject(appData)
70
            case .charger:
Bogdan Timofte authored a month ago
71
                ChargerEditorSheetView()
72
                    .environmentObject(appData)
Bogdan Timofte authored a month ago
73
            case .powerbank:
74
                PowerbankEditorSheetView()
75
                    .environmentObject(appData)
Bogdan Timofte authored a month ago
76
            }
77
        }
Bogdan Timofte authored 2 months ago
78
    }
79

            
80
    private var usbMetersSection: some View {
Bogdan Timofte authored a month ago
81
        Group {
82
            SidebarUSBMetersSectionView(
83
                meters: appData.meterSummaries,
84
                managerState: appData.bluetoothManager.managerState,
85
                hasLiveMeters: appData.meters.isEmpty == false,
86
                scanStartedAt: appData.bluetoothManager.scanStartedAt,
87
                now: now,
88
                noDevicesHelpDelay: noDevicesHelpDelay,
Bogdan Timofte authored a month ago
89
                isExpanded: isUSBMetersExpanded,
90
                onToggle: {
91
                    withAnimation(.easeInOut(duration: 0.22)) {
92
                        isUSBMetersExpanded.toggle()
93
                    }
94
                },
Bogdan Timofte authored a month ago
95
                onAddMeter: { creationSheet = .meter }
96
            )
97

            
98
            SidebarChargedDevicesSectionView(
99
                title: "Devices",
Bogdan Timofte authored a month ago
100
                mode: .device,
Bogdan Timofte authored a month ago
101
                chargedDevices: appData.deviceSummaries,
102
                emptyStateText: "No devices yet. Open Charge Record on a live meter or use the add button here to create one and start learning capacity.",
103
                tint: .orange,
Bogdan Timofte authored a month ago
104
                isExpanded: isDevicesExpanded,
105
                onToggle: {
106
                    withAnimation(.easeInOut(duration: 0.22)) {
107
                        isDevicesExpanded.toggle()
108
                    }
109
                },
Bogdan Timofte authored a month ago
110
                onAdd: { creationSheet = .device }
111
            )
112

            
113
            SidebarChargedDevicesSectionView(
114
                title: "Chargers",
Bogdan Timofte authored a month ago
115
                mode: .charger,
Bogdan Timofte authored a month ago
116
                chargedDevices: appData.chargerSummaries,
117
                emptyStateText: "No chargers yet. Add one here so wireless sessions can track both the charged device and the charger being used.",
118
                tint: .pink,
Bogdan Timofte authored a month ago
119
                isExpanded: isChargersExpanded,
120
                onToggle: {
121
                    withAnimation(.easeInOut(duration: 0.22)) {
122
                        isChargersExpanded.toggle()
123
                    }
124
                },
Bogdan Timofte authored a month ago
125
                onAdd: { creationSheet = .charger }
126
            )
Bogdan Timofte authored a month ago
127

            
128
            SidebarPowerbanksSectionView(
129
                title: "Powerbanks",
130
                powerbanks: appData.powerbankSummaries,
131
                emptyStateText: "No powerbanks yet. Add one here so charging sessions can track the powerbank as either subject or source.",
132
                tint: .yellow,
133
                isExpanded: isPowerbanksExpanded,
134
                onToggle: {
135
                    withAnimation(.easeInOut(duration: 0.22)) {
136
                        isPowerbanksExpanded.toggle()
137
                    }
138
                },
139
                onAdd: { creationSheet = .powerbank }
140
            )
Bogdan Timofte authored a month ago
141
        }
Bogdan Timofte authored 2 months ago
142
    }
143

            
144
    private var helpSection: some View {
145
        SidebarHelpSectionView(
146
            activeReason: activeHelpAutoReason,
147
            isExpanded: helpIsExpanded,
148
            bluetoothStatusTint: appData.bluetoothManager.managerState.color,
149
            bluetoothStatusText: bluetoothStatusText,
150
            cloudSyncHelpTitle: appData.cloudAvailability.helpTitle,
151
            cloudSyncHelpMessage: appData.cloudAvailability.helpMessage,
152
            onToggle: toggleHelpSection,
153
            onOpenSettings: openSettings
154
        ) {
155
            appData.bluetoothManager.managerState.helpView
156
        } deviceHelpDestination: {
157
            DeviceHelpView()
158
        }
159
    }
160

            
161
    private var debugSection: some View {
162
        SidebarDebugSectionView()
163
    }
164

            
165
    private var bluetoothStatusText: String {
166
        switch appData.bluetoothManager.managerState {
167
        case .poweredOff:
168
            return "Off"
169
        case .poweredOn:
170
            return "On"
171
        case .resetting:
172
            return "Resetting"
173
        case .unauthorized:
174
            return "Unauthorized"
175
        case .unknown:
176
            return "Unknown"
177
        case .unsupported:
178
            return "Unsupported"
179
        @unknown default:
180
            return "Other"
181
        }
182
    }
183

            
184
    private var helpIsExpanded: Bool {
185
        isHelpExpanded || shouldAutoExpandHelp
186
    }
187

            
188
    private var shouldAutoExpandHelp: Bool {
189
        guard let activeHelpAutoReason else {
190
            return false
191
        }
192
        return dismissedAutoHelpReason != activeHelpAutoReason
193
    }
194

            
195
    private var activeHelpAutoReason: SidebarHelpReason? {
196
        SidebarAutoHelpResolver.activeReason(
197
            managerState: appData.bluetoothManager.managerState,
198
            cloudAvailability: appData.cloudAvailability,
199
            hasLiveMeters: appData.meters.isEmpty == false,
200
            scanStartedAt: appData.bluetoothManager.scanStartedAt,
201
            now: now,
202
            noDevicesHelpDelay: noDevicesHelpDelay
203
        )
204
    }
205

            
206
    private func toggleHelpSection() {
207
        withAnimation(.easeInOut(duration: 0.22)) {
208
            if shouldAutoExpandHelp {
209
                dismissedAutoHelpReason = activeHelpAutoReason
210
                isHelpExpanded = false
211
            } else {
212
                isHelpExpanded.toggle()
213
            }
214
        }
215
    }
216

            
217
    private func openSettings() {
218
        guard let settingsURL = URL(string: UIApplication.openSettingsURLString) else {
219
            return
220
        }
221
        UIApplication.shared.open(settingsURL, options: [:], completionHandler: nil)
222
    }
223
}
Bogdan Timofte authored a month ago
224

            
225
// MARK: - Meter Editor Sheet
226

            
227
struct MeterEditorSheetView: View {
228
    @EnvironmentObject private var appData: AppData
229
    @Environment(\.dismiss) private var dismiss
230

            
231
    let existingMeterSummary: AppData.MeterSummary?
232

            
233
    @State private var customName: String
234
    @State private var macAddress: String
235
    @State private var advertisedName: String
236
    @State private var selectedModel: Model
237

            
238
    init(existingMeterSummary: AppData.MeterSummary? = nil) {
239
        self.existingMeterSummary = existingMeterSummary
240
        _customName = State(initialValue: existingMeterSummary?.displayName ?? "")
241
        _macAddress = State(initialValue: existingMeterSummary?.macAddress ?? "")
242
        _advertisedName = State(initialValue: existingMeterSummary?.advertisedName ?? "")
243
        _selectedModel = State(initialValue: Self.model(for: existingMeterSummary?.modelSummary))
244
    }
245

            
246
    var body: some View {
247
        NavigationView {
248
            Form {
249
                Section(
250
                    header: ContextInfoHeader(
251
                        title: "Identity",
252
                        message: "Use the real BLE MAC address format `AA:BB:CC:DD:EE:FF`. Saved meters remain visible in the sidebar even when they are currently offline."
253
                    )
254
                ) {
255
                    TextField("Display name", text: $customName)
256
                    TextField("MAC Address", text: $macAddress)
257
                        .textInputAutocapitalization(.characters)
258
                        .disableAutocorrection(true)
259
                        .disabled(existingMeterSummary != nil)
260

            
261
                    Picker("Model", selection: $selectedModel) {
262
                        ForEach(Model.allCases, id: \.self) { model in
263
                            Text(model.canonicalName)
264
                                .tag(model)
265
                        }
266
                    }
267

            
268
                    TextField("Advertised name", text: $advertisedName)
269
                }
270
            }
271
            .navigationTitle(existingMeterSummary == nil ? "New Meter" : "Edit Meter")
272
            .navigationBarTitleDisplayMode(.inline)
273
            .toolbar {
274
                ToolbarItem(placement: .cancellationAction) {
275
                    Button("Cancel") {
276
                        dismiss()
277
                    }
278
                }
279
                ToolbarItem(placement: .confirmationAction) {
280
                    Button(existingMeterSummary == nil ? "Save" : "Update") {
281
                        let normalizedMAC = AppData.normalizedMACAddress(macAddress)
282
                        let didSave = appData.createKnownMeter(
283
                            macAddress: normalizedMAC,
284
                            customName: customName,
285
                            modelName: selectedModel.canonicalName,
286
                            advertisedName: advertisedName
287
                        )
288
                        if didSave {
289
                            dismiss()
290
                        }
291
                    }
292
                    .disabled(isSaveDisabled)
293
                }
294
            }
295
        }
296
        .navigationViewStyle(StackNavigationViewStyle())
297
    }
298

            
299
    private var isSaveDisabled: Bool {
300
        AppData.isValidMACAddress(AppData.normalizedMACAddress(macAddress)) == false
301
    }
302

            
303
    private static func model(for summary: String?) -> Model {
304
        if summary?.contains("UM34C") == true {
305
            return .UM34C
306
        }
307
        if summary?.contains("TC66C") == true {
308
            return .TC66C
309
        }
310
        return .UM25C
311
    }
312
}