USB-Meter / USB Meter / Views / ChargedDevices / ChargedDeviceLibrarySheetView.swift
Newer Older
320 lines | 10.872kb
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
45

            
46
    @Binding var visibility: Bool
47

            
48
    let meterMACAddress: String
49
    let meterTint: Color
50
    let mode: ChargedDeviceLibraryMode
51

            
52
    @State private var editorVisibility = false
53
    @State private var editingChargedDevice: ChargedDeviceSummary?
54

            
55
    var body: some View {
56
        NavigationView {
57
            List {
58
                if displayedChargedDevices.isEmpty {
59
                    VStack(alignment: .leading, spacing: 10) {
Bogdan Timofte authored a month ago
60
                        HStack(spacing: 8) {
61
                            Text("No \(mode.title.lowercased()) yet.")
62
                                .font(.headline)
63
                            ContextInfoButton(
64
                                title: mode.title,
65
                                message: emptyStateDescription
66
                            )
67
                        }
Bogdan Timofte authored a month ago
68
                    }
69
                    .padding(.vertical, 10)
70
                    .listRowBackground(Color.clear)
71
                } else {
72
                    ForEach(displayedChargedDevices) { chargedDevice in
73
                        Button {
74
                            select(chargedDevice)
75
                            visibility = false
76
                        } label: {
77
                            ChargedDeviceLibraryRowView(
78
                                chargedDevice: chargedDevice,
79
                                isSelected: chargedDevice.id == selectedDeviceID
80
                            )
81
                        }
82
                        .buttonStyle(.plain)
83
                        .swipeActions(edge: .trailing, allowsFullSwipe: false) {
84
                            Button {
85
                                editingChargedDevice = chargedDevice
86
                            } label: {
87
                                Label("Edit", systemImage: "pencil")
88
                            }
89
                            .tint(.blue)
90
                        }
91
                        .contextMenu {
92
                            Button {
93
                                editingChargedDevice = chargedDevice
94
                            } label: {
Bogdan Timofte authored a month ago
95
                                Label("Edit \(mode.singularTitle)", systemImage: "pencil")
Bogdan Timofte authored a month ago
96
                            }
97
                        }
98
                    }
99
                }
100
            }
101
            .listStyle(InsetGroupedListStyle())
102
            .background(
103
                LinearGradient(
104
                    colors: [meterTint.opacity(0.14), Color.clear],
105
                    startPoint: .topLeading,
106
                    endPoint: .bottomTrailing
107
                )
108
                .ignoresSafeArea()
109
            )
110
            .navigationTitle(mode.title)
111
            .navigationBarTitleDisplayMode(.inline)
112
            .toolbar {
113
                ToolbarItem(placement: .cancellationAction) {
114
                    Button("Done") {
115
                        visibility = false
116
                    }
117
                }
118
                ToolbarItem(placement: .confirmationAction) {
119
                    Button("New") {
120
                        editorVisibility = true
121
                    }
122
                }
123
            }
124
        }
125
        .navigationViewStyle(StackNavigationViewStyle())
126
        .sheet(isPresented: $editorVisibility) {
127
            ChargedDeviceEditorSheetView(
128
                meterMACAddress: meterMACAddress,
Bogdan Timofte authored a month ago
129
                kind: mode.kind
Bogdan Timofte authored a month ago
130
            )
131
                .environmentObject(appData)
132
        }
133
        .sheet(item: $editingChargedDevice) { chargedDevice in
134
            ChargedDeviceEditorSheetView(
135
                meterMACAddress: nil,
Bogdan Timofte authored a month ago
136
                kind: mode.kind,
137
                chargedDevice: chargedDevice
Bogdan Timofte authored a month ago
138
            )
139
            .environmentObject(appData)
140
        }
141
    }
142

            
143
    private var displayedChargedDevices: [ChargedDeviceSummary] {
144
        switch mode {
145
        case .device:
146
            return appData.deviceSummaries
147
        case .charger:
148
            return appData.chargerSummaries
149
        }
150
    }
151

            
152
    private var selectedDeviceID: UUID? {
153
        switch mode {
154
        case .device:
155
            return appData.currentChargedDeviceSummary(for: meterMACAddress)?.id
156
        case .charger:
157
            return appData.currentChargerSummary(for: meterMACAddress)?.id
158
        }
159
    }
160

            
161
    private var emptyStateDescription: String {
162
        switch mode {
163
        case .device:
164
            return "Create one here, then select it before or during a charging session. The selected device becomes the default for this meter."
165
        case .charger:
166
            return "Create one here, then select it for wireless charging sessions. The selected charger becomes the default wireless source for this meter."
167
        }
168
    }
169

            
170
    private func select(_ chargedDevice: ChargedDeviceSummary) {
171
        switch mode {
172
        case .device:
173
            appData.assignChargedDevice(chargedDevice.id, to: meterMACAddress)
174
        case .charger:
175
            appData.assignCharger(chargedDevice.id, to: meterMACAddress)
176
        }
177
    }
178
}
179

            
180
private struct ChargedDeviceLibraryRowView: View {
181
    let chargedDevice: ChargedDeviceSummary
182
    let isSelected: Bool
183

            
184
    var body: some View {
185
        HStack(alignment: .top, spacing: 14) {
186
            ChargedDeviceQRCodeView(qrIdentifier: chargedDevice.qrIdentifier, side: 58)
187

            
188
            VStack(alignment: .leading, spacing: 6) {
189
                HStack {
Bogdan Timofte authored a month ago
190
                    ChargedDeviceIdentityLabelView(
191
                        chargedDevice: chargedDevice,
192
                        iconPointSize: 17
193
                    )
194
                    .font(.headline)
195
                    .foregroundColor(.primary)
Bogdan Timofte authored a month ago
196
                    Spacer()
197
                    if isSelected {
198
                        Image(systemName: "checkmark.circle.fill")
199
                            .foregroundColor(.green)
200
                    }
201
                }
202

            
Bogdan Timofte authored a month ago
203
                Text(chargedDevice.identityTitle)
Bogdan Timofte authored a month ago
204
                    .font(.caption.weight(.semibold))
205
                    .foregroundColor(.secondary)
206

            
Bogdan Timofte authored a month ago
207
                if chargedDevice.isCharger {
208
                    if !chargedDevice.chargerObservedVoltageSelections.isEmpty {
209
                        Text(
210
                            chargedDevice.chargerObservedVoltageSelections
211
                                .map { "\($0.format(decimalDigits: 1)) V" }
212
                                .joined(separator: ", ")
213
                        )
214
                        .font(.caption2)
Bogdan Timofte authored a month ago
215
                        .foregroundColor(.secondary)
Bogdan Timofte authored a month ago
216
                    } else if let chargerMaximumPowerWatts = chargedDevice.chargerMaximumPowerWatts {
217
                        Text("Max power: \(chargerMaximumPowerWatts.format(decimalDigits: 2)) W")
218
                            .font(.caption2)
219
                            .foregroundColor(.secondary)
220
                    } else {
221
                        Text("Wireless charger")
222
                            .font(.caption2)
223
                            .foregroundColor(.secondary)
224
                    }
225
                } else {
226
                    Text(chargedDevice.supportedChargingModes.map(\.title).joined(separator: " + "))
Bogdan Timofte authored a month ago
227
                        .font(.caption2)
228
                        .foregroundColor(.secondary)
Bogdan Timofte authored a month ago
229

            
230
                    if let capacity = chargedDevice.estimatedBatteryCapacityWh {
231
                        Text("Estimated capacity: \(capacity.format(decimalDigits: 2)) Wh")
232
                            .font(.caption)
233
                            .foregroundColor(.secondary)
234
                    }
235

            
236
                    if let minimumCurrent = chargedDevice.minimumCurrentAmps {
237
                        Text("Completion current: \(minimumCurrent.format(decimalDigits: 2)) A")
238
                            .font(.caption2)
239
                            .foregroundColor(.secondary)
240
                    }
Bogdan Timofte authored a month ago
241
                }
242
            }
243
        }
244
        .padding(.vertical, 4)
245
    }
246
}
Bogdan Timofte authored a month ago
247

            
248
struct ChargedDeviceIdentityLabelView: View {
249
    let chargedDevice: ChargedDeviceSummary
250
    var iconPointSize: CGFloat = 15
251

            
252
    var body: some View {
253
        HStack(alignment: .firstTextBaseline, spacing: 8) {
254
            ChargedDeviceTemplateIconView(
255
                icon: chargedDevice.identityIcon,
256
                fallbackSystemName: chargedDevice.fallbackIdentitySymbolName,
257
                pointSize: iconPointSize
258
            )
259
            Text(chargedDevice.name)
260
        }
261
    }
262
}
263

            
264
struct ChargedDeviceTemplateLabelView: View {
265
    let template: ChargedDeviceTemplateDefinition
266
    var iconPointSize: CGFloat = 15
267

            
268
    var body: some View {
269
        HStack(alignment: .firstTextBaseline, spacing: 8) {
270
            ChargedDeviceTemplateIconView(
271
                icon: template.icon,
272
                fallbackSystemName: template.deviceClass.symbolName,
273
                pointSize: iconPointSize
274
            )
275
            Text(template.name)
276
        }
277
    }
278
}
279

            
280
struct ChargedDeviceTemplateIconView: View {
281
    let icon: ChargedDeviceTemplateIcon
282
    let fallbackSystemName: String
283
    var pointSize: CGFloat = 15
284

            
285
    var body: some View {
286
        Group {
287
            if let assetName = resolvedAssetName {
288
                Image(assetName)
289
                    .renderingMode(.template)
290
                    .resizable()
291
                    .scaledToFit()
292
            } else {
293
                Image(systemName: resolvedSystemSymbolName)
294
                    .font(.system(size: pointSize))
295
            }
296
        }
297
        .frame(width: pointSize + 2, height: pointSize + 2)
298
    }
299

            
300
    private var resolvedAssetName: String? {
301
        guard icon.type == .asset, UIImage(named: icon.name) != nil else {
302
            return nil
303
        }
304
        return icon.name
305
    }
306

            
307
    private var resolvedSystemSymbolName: String {
308
        let candidate = icon.resolvedSystemSymbolName(fallbackSystemName: fallbackSystemName)
309
        if UIImage(systemName: candidate) != nil {
310
            return candidate
311
        }
312

            
313
        if let fallbackSystemName = icon.fallbackSystemName,
314
           UIImage(systemName: fallbackSystemName) != nil {
315
            return fallbackSystemName
316
        }
317

            
318
        return fallbackSystemName
319
    }
320
}