USB-Meter / USB Meter / Views / ChargedDevices / ChargedDeviceLibrarySheetView.swift
Newer Older
386 lines | 12.986kb
Bogdan Timofte authored a month ago
1
//
2
//  ChargedDeviceLibrarySheetView.swift
3
//  USB Meter
4
//
5
//  Created by Codex on 10/04/2026.
6
//
7

            
8
import SwiftUI
Bogdan Timofte authored a month ago
9
import UIKit
Bogdan Timofte authored a month ago
10

            
11
enum ChargedDeviceLibraryMode {
12
    case device
13
    case charger
14

            
Bogdan Timofte authored a month ago
15
    var kind: ChargedDeviceKind {
Bogdan Timofte authored a month ago
16
        switch self {
17
        case .device:
Bogdan Timofte authored a month ago
18
            return .device
Bogdan Timofte authored a month ago
19
        case .charger:
Bogdan Timofte authored a month ago
20
            return .charger
Bogdan Timofte authored a month ago
21
        }
22
    }
23

            
Bogdan Timofte authored a month ago
24
    var title: String {
Bogdan Timofte authored a month ago
25
        switch self {
26
        case .device:
Bogdan Timofte authored a month ago
27
            return "Devices"
Bogdan Timofte authored a month ago
28
        case .charger:
Bogdan Timofte authored a month ago
29
            return "Chargers"
Bogdan Timofte authored a month ago
30
        }
31
    }
32

            
Bogdan Timofte authored a month ago
33
    var singularTitle: String {
Bogdan Timofte authored a month ago
34
        switch self {
35
        case .device:
Bogdan Timofte authored a month ago
36
            return "Device"
Bogdan Timofte authored a month ago
37
        case .charger:
Bogdan Timofte authored a month ago
38
            return "Charger"
Bogdan Timofte authored a month ago
39
        }
40
    }
41
}
42

            
43
struct ChargedDeviceLibrarySheetView: View {
44
    @EnvironmentObject private var appData: AppData
Bogdan Timofte authored a month ago
45
    @Environment(\.dismiss) private var dismiss
Bogdan Timofte authored a month ago
46

            
47
    let meterMACAddress: String
48
    let meterTint: Color
49
    let mode: ChargedDeviceLibraryMode
Bogdan Timofte authored a month ago
50
    /// true = standalone sheet with own NavigationView; false = pushed into parent nav stack
51
    let standalone: Bool
Bogdan Timofte authored a month ago
52

            
Bogdan Timofte authored a month ago
53
    @State private var showingNewEditor = false
Bogdan Timofte authored a month ago
54
    @State private var editingChargedDevice: ChargedDeviceSummary?
Bogdan Timofte authored a month ago
55
    @State private var pendingDeletion: ChargedDeviceSummary?
56

            
57
    init(
58
        meterMACAddress: String,
59
        meterTint: Color,
60
        mode: ChargedDeviceLibraryMode,
61
        standalone: Bool = true
62
    ) {
63
        self.meterMACAddress = meterMACAddress
64
        self.meterTint = meterTint
65
        self.mode = mode
66
        self.standalone = standalone
67
    }
Bogdan Timofte authored a month ago
68

            
69
    var body: some View {
Bogdan Timofte authored a month ago
70
        if standalone {
71
            NavigationView { listContent }
72
                .navigationViewStyle(StackNavigationViewStyle())
73
        } else {
74
            listContent
75
        }
76
    }
77

            
78
    private var listContent: some View {
79
        List {
80
            if displayedChargedDevices.isEmpty {
81
                VStack(alignment: .leading, spacing: 10) {
82
                    HStack(spacing: 8) {
83
                        Text("No \(mode.title.lowercased()) yet.")
84
                            .font(.headline)
85
                        ContextInfoButton(
86
                            title: mode.title,
87
                            message: emptyStateDescription
88
                        )
Bogdan Timofte authored a month ago
89
                    }
Bogdan Timofte authored a month ago
90
                }
91
                .padding(.vertical, 10)
92
                .listRowBackground(Color.clear)
93
            } else {
94
                ForEach(displayedChargedDevices) { chargedDevice in
95
                    Button {
96
                        select(chargedDevice)
97
                        dismiss()
98
                    } label: {
99
                        ChargedDeviceLibraryRowView(
100
                            chargedDevice: chargedDevice,
101
                            isSelected: chargedDevice.id == selectedDeviceID
102
                        )
103
                    }
104
                    .buttonStyle(.plain)
105
                    .swipeActions(edge: .trailing, allowsFullSwipe: false) {
106
                        Button(role: .destructive) {
107
                            pendingDeletion = chargedDevice
108
                        } label: {
109
                            Label("Delete", systemImage: "trash")
110
                        }
Bogdan Timofte authored a month ago
111
                        Button {
Bogdan Timofte authored a month ago
112
                            editingChargedDevice = chargedDevice
Bogdan Timofte authored a month ago
113
                        } label: {
Bogdan Timofte authored a month ago
114
                            Label("Edit", systemImage: "pencil")
Bogdan Timofte authored a month ago
115
                        }
Bogdan Timofte authored a month ago
116
                        .tint(.blue)
117
                    }
118
                    .contextMenu {
119
                        Button {
120
                            editingChargedDevice = chargedDevice
121
                        } label: {
122
                            Label("Edit \(mode.singularTitle)", systemImage: "pencil")
Bogdan Timofte authored a month ago
123
                        }
Bogdan Timofte authored a month ago
124
                        Button(role: .destructive) {
125
                            pendingDeletion = chargedDevice
126
                        } label: {
127
                            Label("Delete \(mode.singularTitle)", systemImage: "trash")
Bogdan Timofte authored a month ago
128
                        }
129
                    }
130
                }
131
            }
Bogdan Timofte authored a month ago
132
        }
133
        .listStyle(InsetGroupedListStyle())
134
        .background(
135
            LinearGradient(
136
                colors: [meterTint.opacity(0.14), Color.clear],
137
                startPoint: .topLeading,
138
                endPoint: .bottomTrailing
Bogdan Timofte authored a month ago
139
            )
Bogdan Timofte authored a month ago
140
            .ignoresSafeArea()
141
        )
142
        .navigationTitle(mode.title)
143
        .navigationBarTitleDisplayMode(.inline)
144
        .toolbar {
145
            ToolbarItem(placement: .cancellationAction) {
146
                if standalone {
147
                    Button("Done") { dismiss() }
Bogdan Timofte authored a month ago
148
                }
Bogdan Timofte authored a month ago
149
            }
150
            ToolbarItem(placement: .confirmationAction) {
151
                Button("New") { showingNewEditor = true }
152
            }
153
        }
154
        .sheet(isPresented: $showingNewEditor) {
155
            newEditorSheet
156
        }
157
        .sheet(item: $editingChargedDevice) { device in
158
            editEditorSheet(device)
159
        }
160
        .confirmationDialog(
161
            "Delete \(pendingDeletion?.name ?? mode.singularTitle)?",
162
            isPresented: Binding(
163
                get: { pendingDeletion != nil },
164
                set: { if !$0 { pendingDeletion = nil } }
165
            ),
166
            titleVisibility: .visible
167
        ) {
168
            Button("Delete", role: .destructive) {
169
                if let device = pendingDeletion {
170
                    _ = appData.deleteChargedDevice(id: device.id)
171
                    pendingDeletion = nil
Bogdan Timofte authored a month ago
172
                }
173
            }
Bogdan Timofte authored a month ago
174
            Button("Cancel", role: .cancel) { pendingDeletion = nil }
175
        } message: {
176
            Text("This will permanently remove the \(mode.singularTitle.lowercased()) and all associated data.")
Bogdan Timofte authored a month ago
177
        }
Bogdan Timofte authored a month ago
178
    }
179

            
180
    @ViewBuilder
181
    private var newEditorSheet: some View {
182
        if mode == .charger {
183
            ChargerEditorSheetView(
184
                appData: appData,
185
                meterMACAddress: meterMACAddress
Bogdan Timofte authored a month ago
186
            )
Bogdan Timofte authored a month ago
187
        } else {
188
            ChargedDeviceEditorSheetView(meterMACAddress: meterMACAddress)
Bogdan Timofte authored a month ago
189
                .environmentObject(appData)
190
        }
Bogdan Timofte authored a month ago
191
    }
192

            
193
    @ViewBuilder
194
    private func editEditorSheet(_ chargedDevice: ChargedDeviceSummary) -> some View {
195
        if chargedDevice.isCharger {
196
            ChargerEditorSheetView(
197
                appData: appData,
198
                chargedDevice: chargedDevice
199
            )
200
        } else {
Bogdan Timofte authored a month ago
201
            ChargedDeviceEditorSheetView(
202
                meterMACAddress: nil,
Bogdan Timofte authored a month ago
203
                chargedDevice: chargedDevice
Bogdan Timofte authored a month ago
204
            )
205
            .environmentObject(appData)
206
        }
207
    }
208

            
209
    private var displayedChargedDevices: [ChargedDeviceSummary] {
210
        switch mode {
211
        case .device:
212
            return appData.deviceSummaries
213
        case .charger:
214
            return appData.chargerSummaries
215
        }
216
    }
217

            
218
    private var selectedDeviceID: UUID? {
219
        switch mode {
220
        case .device:
221
            return appData.currentChargedDeviceSummary(for: meterMACAddress)?.id
222
        case .charger:
223
            return appData.currentChargerSummary(for: meterMACAddress)?.id
224
        }
225
    }
226

            
227
    private var emptyStateDescription: String {
228
        switch mode {
229
        case .device:
230
            return "Create one here, then select it before or during a charging session. The selected device becomes the default for this meter."
231
        case .charger:
232
            return "Create one here, then select it for wireless charging sessions. The selected charger becomes the default wireless source for this meter."
233
        }
234
    }
235

            
236
    private func select(_ chargedDevice: ChargedDeviceSummary) {
237
        switch mode {
238
        case .device:
239
            appData.assignChargedDevice(chargedDevice.id, to: meterMACAddress)
240
        case .charger:
241
            appData.assignCharger(chargedDevice.id, to: meterMACAddress)
242
        }
243
    }
244
}
245

            
246
private struct ChargedDeviceLibraryRowView: View {
247
    let chargedDevice: ChargedDeviceSummary
248
    let isSelected: Bool
249

            
250
    var body: some View {
251
        HStack(alignment: .top, spacing: 14) {
252
            ChargedDeviceQRCodeView(qrIdentifier: chargedDevice.qrIdentifier, side: 58)
253

            
254
            VStack(alignment: .leading, spacing: 6) {
255
                HStack {
Bogdan Timofte authored a month ago
256
                    ChargedDeviceIdentityLabelView(
257
                        chargedDevice: chargedDevice,
258
                        iconPointSize: 17
259
                    )
260
                    .font(.headline)
261
                    .foregroundColor(.primary)
Bogdan Timofte authored a month ago
262
                    Spacer()
263
                    if isSelected {
264
                        Image(systemName: "checkmark.circle.fill")
265
                            .foregroundColor(.green)
266
                    }
267
                }
268

            
Bogdan Timofte authored a month ago
269
                Text(chargedDevice.identityTitle)
Bogdan Timofte authored a month ago
270
                    .font(.caption.weight(.semibold))
271
                    .foregroundColor(.secondary)
272

            
Bogdan Timofte authored a month ago
273
                if chargedDevice.isCharger {
274
                    if !chargedDevice.chargerObservedVoltageSelections.isEmpty {
275
                        Text(
276
                            chargedDevice.chargerObservedVoltageSelections
277
                                .map { "\($0.format(decimalDigits: 1)) V" }
278
                                .joined(separator: ", ")
279
                        )
280
                        .font(.caption2)
Bogdan Timofte authored a month ago
281
                        .foregroundColor(.secondary)
Bogdan Timofte authored a month ago
282
                    } else if let chargerMaximumPowerWatts = chargedDevice.chargerMaximumPowerWatts {
283
                        Text("Max power: \(chargerMaximumPowerWatts.format(decimalDigits: 2)) W")
284
                            .font(.caption2)
285
                            .foregroundColor(.secondary)
286
                    } else {
287
                        Text("Wireless charger")
288
                            .font(.caption2)
289
                            .foregroundColor(.secondary)
290
                    }
291
                } else {
292
                    Text(chargedDevice.supportedChargingModes.map(\.title).joined(separator: " + "))
Bogdan Timofte authored a month ago
293
                        .font(.caption2)
294
                        .foregroundColor(.secondary)
Bogdan Timofte authored a month ago
295

            
296
                    if let capacity = chargedDevice.estimatedBatteryCapacityWh {
297
                        Text("Estimated capacity: \(capacity.format(decimalDigits: 2)) Wh")
298
                            .font(.caption)
299
                            .foregroundColor(.secondary)
300
                    }
301

            
302
                    if let minimumCurrent = chargedDevice.minimumCurrentAmps {
303
                        Text("Completion current: \(minimumCurrent.format(decimalDigits: 2)) A")
304
                            .font(.caption2)
305
                            .foregroundColor(.secondary)
306
                    }
Bogdan Timofte authored a month ago
307
                }
308
            }
309
        }
310
        .padding(.vertical, 4)
311
    }
312
}
Bogdan Timofte authored a month ago
313

            
314
struct ChargedDeviceIdentityLabelView: View {
315
    let chargedDevice: ChargedDeviceSummary
316
    var iconPointSize: CGFloat = 15
317

            
318
    var body: some View {
319
        HStack(alignment: .firstTextBaseline, spacing: 8) {
320
            ChargedDeviceTemplateIconView(
321
                icon: chargedDevice.identityIcon,
322
                fallbackSystemName: chargedDevice.fallbackIdentitySymbolName,
323
                pointSize: iconPointSize
324
            )
325
            Text(chargedDevice.name)
326
        }
327
    }
328
}
329

            
330
struct ChargedDeviceTemplateLabelView: View {
331
    let template: ChargedDeviceTemplateDefinition
332
    var iconPointSize: CGFloat = 15
333

            
334
    var body: some View {
335
        HStack(alignment: .firstTextBaseline, spacing: 8) {
336
            ChargedDeviceTemplateIconView(
337
                icon: template.icon,
338
                fallbackSystemName: template.deviceClass.symbolName,
339
                pointSize: iconPointSize
340
            )
341
            Text(template.name)
342
        }
343
    }
344
}
345

            
346
struct ChargedDeviceTemplateIconView: View {
347
    let icon: ChargedDeviceTemplateIcon
348
    let fallbackSystemName: String
349
    var pointSize: CGFloat = 15
350

            
351
    var body: some View {
352
        Group {
353
            if let assetName = resolvedAssetName {
354
                Image(assetName)
355
                    .renderingMode(.template)
356
                    .resizable()
357
                    .scaledToFit()
358
            } else {
359
                Image(systemName: resolvedSystemSymbolName)
360
                    .font(.system(size: pointSize))
361
            }
362
        }
363
        .frame(width: pointSize + 2, height: pointSize + 2)
364
    }
365

            
366
    private var resolvedAssetName: String? {
367
        guard icon.type == .asset, UIImage(named: icon.name) != nil else {
368
            return nil
369
        }
370
        return icon.name
371
    }
372

            
373
    private var resolvedSystemSymbolName: String {
374
        let candidate = icon.resolvedSystemSymbolName(fallbackSystemName: fallbackSystemName)
375
        if UIImage(systemName: candidate) != nil {
376
            return candidate
377
        }
378

            
379
        if let fallbackSystemName = icon.fallbackSystemName,
380
           UIImage(systemName: fallbackSystemName) != nil {
381
            return fallbackSystemName
382
        }
383

            
384
        return fallbackSystemName
385
    }
386
}