Newer Older
291 lines | 10.172kb
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
13

            
14
    var id: String {
15
        switch self {
16
        case .meter:
17
            return "meter"
18
        case .device:
19
            return "device"
20
        case .charger:
21
            return "charger"
22
        }
23
    }
24
}
25

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

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

            
73
    private var usbMetersSection: some View {
Bogdan Timofte authored a month ago
74
        Group {
75
            SidebarUSBMetersSectionView(
76
                meters: appData.meterSummaries,
77
                managerState: appData.bluetoothManager.managerState,
78
                hasLiveMeters: appData.meters.isEmpty == false,
79
                scanStartedAt: appData.bluetoothManager.scanStartedAt,
80
                now: now,
81
                noDevicesHelpDelay: noDevicesHelpDelay,
Bogdan Timofte authored a month ago
82
                isExpanded: isUSBMetersExpanded,
83
                onToggle: {
84
                    withAnimation(.easeInOut(duration: 0.22)) {
85
                        isUSBMetersExpanded.toggle()
86
                    }
87
                },
Bogdan Timofte authored a month ago
88
                onAddMeter: { creationSheet = .meter }
89
            )
90

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

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

            
123
    private var helpSection: some View {
124
        SidebarHelpSectionView(
125
            activeReason: activeHelpAutoReason,
126
            isExpanded: helpIsExpanded,
127
            bluetoothStatusTint: appData.bluetoothManager.managerState.color,
128
            bluetoothStatusText: bluetoothStatusText,
129
            cloudSyncHelpTitle: appData.cloudAvailability.helpTitle,
130
            cloudSyncHelpMessage: appData.cloudAvailability.helpMessage,
131
            onToggle: toggleHelpSection,
132
            onOpenSettings: openSettings
133
        ) {
134
            appData.bluetoothManager.managerState.helpView
135
        } deviceHelpDestination: {
136
            DeviceHelpView()
137
        }
138
    }
139

            
140
    private var debugSection: some View {
141
        SidebarDebugSectionView()
142
    }
143

            
144
    private var bluetoothStatusText: String {
145
        switch appData.bluetoothManager.managerState {
146
        case .poweredOff:
147
            return "Off"
148
        case .poweredOn:
149
            return "On"
150
        case .resetting:
151
            return "Resetting"
152
        case .unauthorized:
153
            return "Unauthorized"
154
        case .unknown:
155
            return "Unknown"
156
        case .unsupported:
157
            return "Unsupported"
158
        @unknown default:
159
            return "Other"
160
        }
161
    }
162

            
163
    private var helpIsExpanded: Bool {
164
        isHelpExpanded || shouldAutoExpandHelp
165
    }
166

            
167
    private var shouldAutoExpandHelp: Bool {
168
        guard let activeHelpAutoReason else {
169
            return false
170
        }
171
        return dismissedAutoHelpReason != activeHelpAutoReason
172
    }
173

            
174
    private var activeHelpAutoReason: SidebarHelpReason? {
175
        SidebarAutoHelpResolver.activeReason(
176
            managerState: appData.bluetoothManager.managerState,
177
            cloudAvailability: appData.cloudAvailability,
178
            hasLiveMeters: appData.meters.isEmpty == false,
179
            scanStartedAt: appData.bluetoothManager.scanStartedAt,
180
            now: now,
181
            noDevicesHelpDelay: noDevicesHelpDelay
182
        )
183
    }
184

            
185
    private func toggleHelpSection() {
186
        withAnimation(.easeInOut(duration: 0.22)) {
187
            if shouldAutoExpandHelp {
188
                dismissedAutoHelpReason = activeHelpAutoReason
189
                isHelpExpanded = false
190
            } else {
191
                isHelpExpanded.toggle()
192
            }
193
        }
194
    }
195

            
196
    private func openSettings() {
197
        guard let settingsURL = URL(string: UIApplication.openSettingsURLString) else {
198
            return
199
        }
200
        UIApplication.shared.open(settingsURL, options: [:], completionHandler: nil)
201
    }
202
}
Bogdan Timofte authored a month ago
203

            
204
// MARK: - Meter Editor Sheet
205

            
206
struct MeterEditorSheetView: View {
207
    @EnvironmentObject private var appData: AppData
208
    @Environment(\.dismiss) private var dismiss
209

            
210
    let existingMeterSummary: AppData.MeterSummary?
211

            
212
    @State private var customName: String
213
    @State private var macAddress: String
214
    @State private var advertisedName: String
215
    @State private var selectedModel: Model
216

            
217
    init(existingMeterSummary: AppData.MeterSummary? = nil) {
218
        self.existingMeterSummary = existingMeterSummary
219
        _customName = State(initialValue: existingMeterSummary?.displayName ?? "")
220
        _macAddress = State(initialValue: existingMeterSummary?.macAddress ?? "")
221
        _advertisedName = State(initialValue: existingMeterSummary?.advertisedName ?? "")
222
        _selectedModel = State(initialValue: Self.model(for: existingMeterSummary?.modelSummary))
223
    }
224

            
225
    var body: some View {
226
        NavigationView {
227
            Form {
228
                Section(
229
                    header: ContextInfoHeader(
230
                        title: "Identity",
231
                        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."
232
                    )
233
                ) {
234
                    TextField("Display name", text: $customName)
235
                    TextField("MAC Address", text: $macAddress)
236
                        .textInputAutocapitalization(.characters)
237
                        .disableAutocorrection(true)
238
                        .disabled(existingMeterSummary != nil)
239

            
240
                    Picker("Model", selection: $selectedModel) {
241
                        ForEach(Model.allCases, id: \.self) { model in
242
                            Text(model.canonicalName)
243
                                .tag(model)
244
                        }
245
                    }
246

            
247
                    TextField("Advertised name", text: $advertisedName)
248
                }
249
            }
250
            .navigationTitle(existingMeterSummary == nil ? "New Meter" : "Edit Meter")
251
            .navigationBarTitleDisplayMode(.inline)
252
            .toolbar {
253
                ToolbarItem(placement: .cancellationAction) {
254
                    Button("Cancel") {
255
                        dismiss()
256
                    }
257
                }
258
                ToolbarItem(placement: .confirmationAction) {
259
                    Button(existingMeterSummary == nil ? "Save" : "Update") {
260
                        let normalizedMAC = AppData.normalizedMACAddress(macAddress)
261
                        let didSave = appData.createKnownMeter(
262
                            macAddress: normalizedMAC,
263
                            customName: customName,
264
                            modelName: selectedModel.canonicalName,
265
                            advertisedName: advertisedName
266
                        )
267
                        if didSave {
268
                            dismiss()
269
                        }
270
                    }
271
                    .disabled(isSaveDisabled)
272
                }
273
            }
274
        }
275
        .navigationViewStyle(StackNavigationViewStyle())
276
    }
277

            
278
    private var isSaveDisabled: Bool {
279
        AppData.isValidMACAddress(AppData.normalizedMACAddress(macAddress)) == false
280
    }
281

            
282
    private static func model(for summary: String?) -> Model {
283
        if summary?.contains("UM34C") == true {
284
            return .UM34C
285
        }
286
        if summary?.contains("TC66C") == true {
287
            return .TC66C
288
        }
289
        return .UM25C
290
    }
291
}