Newer Older
293 lines | 10.23kb
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:
64
                ChargedDeviceEditorSheetView(
Bogdan Timofte authored a month ago
65
                    meterMACAddress: nil
Bogdan Timofte authored a month ago
66
                )
67
                .environmentObject(appData)
68
            case .charger:
Bogdan Timofte authored a month ago
69
                ChargerEditorSheetView()
70
                    .environmentObject(appData)
Bogdan Timofte authored a month ago
71
            }
72
        }
Bogdan Timofte authored 2 months ago
73
    }
74

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

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

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

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

            
142
    private var debugSection: some View {
143
        SidebarDebugSectionView()
144
    }
145

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

            
165
    private var helpIsExpanded: Bool {
166
        isHelpExpanded || shouldAutoExpandHelp
167
    }
168

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

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

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

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

            
206
// MARK: - Meter Editor Sheet
207

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

            
212
    let existingMeterSummary: AppData.MeterSummary?
213

            
214
    @State private var customName: String
215
    @State private var macAddress: String
216
    @State private var advertisedName: String
217
    @State private var selectedModel: Model
218

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

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

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

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

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

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