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

            
8
import SwiftUI
9

            
10
struct ChargedDeviceEditorSheetView: View {
11
    @EnvironmentObject private var appData: AppData
12
    @Environment(\.dismiss) private var dismiss
13

            
14
    let meterMACAddress: String?
15
    let chargedDevice: ChargedDeviceSummary?
16
    let suggestedDeviceClass: ChargedDeviceClass?
17

            
18
    @State private var name: String
19
    @State private var deviceClass: ChargedDeviceClass
Bogdan Timofte authored a month ago
20
    @State private var chargingStateAvailability: ChargingStateAvailability
Bogdan Timofte authored a month ago
21
    @State private var supportsWiredCharging: Bool
22
    @State private var supportsWirelessCharging: Bool
23
    @State private var preferredChargingTransportMode: ChargingTransportMode
24
    @State private var wirelessChargingProfile: WirelessChargingProfile
Bogdan Timofte authored a month ago
25
    @State private var completionCurrentTexts: [ChargeSessionKind: String]
Bogdan Timofte authored a month ago
26
    @State private var notes: String
27

            
28
    init(
29
        meterMACAddress: String?,
30
        chargedDevice: ChargedDeviceSummary? = nil,
31
        suggestedDeviceClass: ChargedDeviceClass? = nil
32
    ) {
33
        self.meterMACAddress = meterMACAddress
34
        self.chargedDevice = chargedDevice
35
        self.suggestedDeviceClass = suggestedDeviceClass
Bogdan Timofte authored a month ago
36

            
Bogdan Timofte authored a month ago
37
        let initialDeviceClass = chargedDevice?.deviceClass ?? suggestedDeviceClass ?? .iphone
38
        _name = State(initialValue: chargedDevice?.name ?? "")
39
        _deviceClass = State(initialValue: initialDeviceClass)
Bogdan Timofte authored a month ago
40
        _chargingStateAvailability = State(initialValue: chargedDevice?.chargingStateAvailability ?? Self.suggestedChargingStateAvailability(for: initialDeviceClass))
Bogdan Timofte authored a month ago
41
        _supportsWiredCharging = State(initialValue: chargedDevice?.supportsWiredCharging ?? true)
42
        _supportsWirelessCharging = State(initialValue: chargedDevice?.supportsWirelessCharging ?? true)
43
        _preferredChargingTransportMode = State(initialValue: chargedDevice?.preferredChargingTransportMode ?? .wired)
44
        _wirelessChargingProfile = State(initialValue: chargedDevice?.wirelessChargingProfile ?? .genericQi)
Bogdan Timofte authored a month ago
45
        _completionCurrentTexts = State(
46
            initialValue: Self.makeCompletionCurrentTexts(for: chargedDevice)
47
        )
Bogdan Timofte authored a month ago
48
        _notes = State(initialValue: chargedDevice?.notes ?? "")
49
    }
50

            
51
    var body: some View {
52
        NavigationView {
53
            Form {
54
                Section(header: Text("Identity")) {
55
                    TextField("Name", text: $name)
56

            
57
                    Picker("Class", selection: $deviceClass) {
58
                        ForEach(ChargedDeviceClass.allCases) { deviceClass in
59
                            Label(deviceClass.title, systemImage: deviceClass.symbolName)
60
                                .tag(deviceClass)
61
                        }
62
                    }
63

            
64
                    if let chargedDevice {
65
                        Text(chargedDevice.qrIdentifier)
66
                            .font(.caption.monospaced())
67
                            .foregroundColor(.secondary)
68
                            .textSelection(.enabled)
69
                    }
70
                }
71

            
72
                Section(header: Text("Charge Behaviour")) {
Bogdan Timofte authored a month ago
73
                    Picker("Session Modes", selection: $chargingStateAvailability) {
74
                        ForEach(ChargingStateAvailability.allCases) { availability in
75
                            Text(availability.title)
76
                                .tag(availability)
77
                        }
78
                    }
79

            
80
                    Text(chargingStateAvailability.description)
Bogdan Timofte authored a month ago
81
                        .font(.footnote)
82
                        .foregroundColor(.secondary)
83
                }
84

            
85
                Section(header: Text("Charging Support")) {
86
                    Toggle("Supports wired charging", isOn: $supportsWiredCharging)
87
                    Toggle("Supports wireless charging", isOn: $supportsWirelessCharging)
88

            
89
                    if supportsWirelessCharging {
90
                        Picker("Wireless profile", selection: $wirelessChargingProfile) {
91
                            ForEach(WirelessChargingProfile.allCases) { profile in
92
                                Text(profile.title)
93
                                    .tag(profile)
94
                            }
95
                        }
96

            
97
                        Text(wirelessChargingProfile.description)
98
                            .font(.footnote)
99
                            .foregroundColor(.secondary)
100
                    }
101

            
Bogdan Timofte authored a month ago
102
                    if !supportedChargingModes.isEmpty {
Bogdan Timofte authored a month ago
103
                        Picker("Default session type", selection: preferredChargingTransportBinding) {
104
                            ForEach(supportedChargingModes) { chargingTransportMode in
105
                                Label(chargingTransportMode.title, systemImage: chargingTransportMode.symbolName)
106
                                    .tag(chargingTransportMode)
107
                            }
108
                        }
109
                    } else {
110
                        Text("Enable at least one charging method.")
111
                            .font(.footnote)
112
                            .foregroundColor(.secondary)
113
                    }
114
                }
115

            
116
                Section(header: Text("Charge Completion")) {
Bogdan Timofte authored a month ago
117
                    if applicableSessionKinds.isEmpty {
118
                        Text("Enable at least one charging method to configure stop currents.")
Bogdan Timofte authored a month ago
119
                            .font(.footnote)
120
                            .foregroundColor(.secondary)
Bogdan Timofte authored a month ago
121
                    } else {
122
                        ForEach(applicableSessionKinds) { sessionKind in
123
                            VStack(alignment: .leading, spacing: 6) {
124
                                TextField(
125
                                    "\(sessionKind.shortTitle) completion current (A)",
126
                                    text: completionCurrentTextBinding(for: sessionKind)
127
                                )
128
                                .keyboardType(.decimalPad)
Bogdan Timofte authored a month ago
129

            
Bogdan Timofte authored a month ago
130
                                Text("Leave empty to keep learning this threshold from sessions of the same type.")
131
                                    .font(.caption)
132
                                    .foregroundColor(.secondary)
133
                            }
134
                            .padding(.vertical, 2)
135
                        }
Bogdan Timofte authored a month ago
136
                    }
137
                }
138

            
139
                Section(header: Text("Notes")) {
140
                    TextField("Optional notes", text: $notes)
141
                }
142
            }
143
            .navigationTitle(editorTitle)
144
            .navigationBarTitleDisplayMode(.inline)
145
            .toolbar {
146
                ToolbarItem(placement: .cancellationAction) {
147
                    Button("Cancel") {
148
                        dismiss()
149
                    }
150
                }
151
                ToolbarItem(placement: .confirmationAction) {
152
                    Button(saveButtonTitle) {
Bogdan Timofte authored a month ago
153
                        let configuredCompletionCurrents = parsedCompletionCurrents
Bogdan Timofte authored a month ago
154
                        let didSave: Bool
155
                        if let chargedDevice {
156
                            didSave = appData.updateChargedDevice(
157
                                id: chargedDevice.id,
158
                                name: name,
159
                                deviceClass: deviceClass,
Bogdan Timofte authored a month ago
160
                                chargingStateAvailability: chargingStateAvailability,
Bogdan Timofte authored a month ago
161
                                supportsWiredCharging: supportsWiredCharging,
162
                                supportsWirelessCharging: supportsWirelessCharging,
163
                                preferredChargingTransportMode: resolvedPreferredChargingTransportMode,
164
                                wirelessChargingProfile: wirelessChargingProfile,
Bogdan Timofte authored a month ago
165
                                configuredCompletionCurrents: configuredCompletionCurrents,
Bogdan Timofte authored a month ago
166
                                notes: notes
167
                            )
168
                        } else {
169
                            didSave = appData.createChargedDevice(
170
                                name: name,
171
                                deviceClass: deviceClass,
Bogdan Timofte authored a month ago
172
                                chargingStateAvailability: chargingStateAvailability,
Bogdan Timofte authored a month ago
173
                                supportsWiredCharging: supportsWiredCharging,
174
                                supportsWirelessCharging: supportsWirelessCharging,
175
                                preferredChargingTransportMode: resolvedPreferredChargingTransportMode,
176
                                wirelessChargingProfile: wirelessChargingProfile,
Bogdan Timofte authored a month ago
177
                                configuredCompletionCurrents: configuredCompletionCurrents,
Bogdan Timofte authored a month ago
178
                                notes: notes,
179
                                meterMACAddress: meterMACAddress
180
                            )
181
                        }
182

            
183
                        if didSave {
184
                            dismiss()
185
                        }
186
                    }
187
                    .disabled(
188
                        name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
189
                            || (!supportsWiredCharging && !supportsWirelessCharging)
Bogdan Timofte authored a month ago
190
                            || hasInvalidCompletionCurrentEntry
Bogdan Timofte authored a month ago
191
                    )
192
                }
193
            }
194
        }
195
        .navigationViewStyle(StackNavigationViewStyle())
196
        .onChange(of: deviceClass) { newValue in
197
            applySuggestedChargingSupport(for: newValue)
198
        }
199
        .onAppear {
200
            guard chargedDevice == nil else {
201
                return
202
            }
203
            applySuggestedChargingSupport(for: deviceClass)
204
        }
205
    }
206

            
207
    private var editorTitle: String {
208
        if chargedDevice == nil {
209
            return deviceClass == .charger ? "New Charger" : "New Device"
210
        }
211
        return chargedDevice?.isCharger == true ? "Edit Charger" : "Edit Device"
212
    }
213

            
214
    private var saveButtonTitle: String {
215
        chargedDevice == nil ? "Save" : "Update"
216
    }
217

            
218
    private var supportedChargingModes: [ChargingTransportMode] {
219
        var modes: [ChargingTransportMode] = []
220
        if supportsWiredCharging {
221
            modes.append(.wired)
222
        }
223
        if supportsWirelessCharging {
224
            modes.append(.wireless)
225
        }
226
        return modes
227
    }
228

            
Bogdan Timofte authored a month ago
229
    private var applicableSessionKinds: [ChargeSessionKind] {
230
        supportedChargingModes.flatMap { chargingTransportMode in
231
            chargingStateAvailability.supportedModes.map { chargingStateMode in
232
                ChargeSessionKind(
233
                    chargingTransportMode: chargingTransportMode,
234
                    chargingStateMode: chargingStateMode
235
                )
236
            }
237
        }
238
    }
239

            
240
    private var parsedCompletionCurrents: [ChargeSessionKind: Double] {
241
        applicableSessionKinds.reduce(into: [ChargeSessionKind: Double]()) { result, sessionKind in
242
            guard let value = parsedOptionalCurrent(completionCurrentTexts[sessionKind] ?? "") else {
243
                return
244
            }
245
            result[sessionKind] = value
246
        }
247
    }
248

            
249
    private var hasInvalidCompletionCurrentEntry: Bool {
250
        applicableSessionKinds.contains { sessionKind in
251
            let text = completionCurrentTexts[sessionKind] ?? ""
252
            let normalized = text.trimmingCharacters(in: .whitespacesAndNewlines)
253
            return !normalized.isEmpty && parsedOptionalCurrent(text) == nil
254
        }
255
    }
256

            
Bogdan Timofte authored a month ago
257
    private var resolvedPreferredChargingTransportMode: ChargingTransportMode {
258
        if supportedChargingModes.contains(preferredChargingTransportMode) {
259
            return preferredChargingTransportMode
260
        }
261
        return supportsWiredCharging ? .wired : .wireless
262
    }
263

            
264
    private var preferredChargingTransportBinding: Binding<ChargingTransportMode> {
265
        Binding(
266
            get: { resolvedPreferredChargingTransportMode },
267
            set: { preferredChargingTransportMode = $0 }
268
        )
269
    }
270

            
Bogdan Timofte authored a month ago
271
    private func completionCurrentTextBinding(for sessionKind: ChargeSessionKind) -> Binding<String> {
272
        Binding(
273
            get: { completionCurrentTexts[sessionKind] ?? "" },
274
            set: { completionCurrentTexts[sessionKind] = $0 }
275
        )
276
    }
277

            
Bogdan Timofte authored a month ago
278
    private func applySuggestedChargingSupport(for deviceClass: ChargedDeviceClass) {
Bogdan Timofte authored a month ago
279
        if chargedDevice != nil {
280
            return
281
        }
282

            
283
        chargingStateAvailability = Self.suggestedChargingStateAvailability(for: deviceClass)
284

            
Bogdan Timofte authored a month ago
285
        switch deviceClass {
286
        case .iphone:
287
            supportsWiredCharging = true
288
            supportsWirelessCharging = true
289
            preferredChargingTransportMode = .wired
290
        case .watch:
291
            supportsWiredCharging = false
292
            supportsWirelessCharging = true
293
            preferredChargingTransportMode = .wireless
294
        case .powerbank:
295
            supportsWiredCharging = true
296
            supportsWirelessCharging = false
297
            preferredChargingTransportMode = .wired
298
        case .charger:
299
            supportsWiredCharging = true
300
            supportsWirelessCharging = true
301
            preferredChargingTransportMode = .wireless
302
        case .other:
303
            supportsWiredCharging = true
304
            supportsWirelessCharging = false
305
            preferredChargingTransportMode = .wired
306
        }
307
    }
308

            
309
    private func parsedOptionalCurrent(_ text: String) -> Double? {
310
        let normalized = text
311
            .trimmingCharacters(in: .whitespacesAndNewlines)
312
            .replacingOccurrences(of: ",", with: ".")
313
        guard !normalized.isEmpty else {
314
            return nil
315
        }
Bogdan Timofte authored a month ago
316
        guard let value = Double(normalized), value > 0 else {
317
            return nil
318
        }
319
        return value
320
    }
321

            
322
    private static func makeCompletionCurrentTexts(for chargedDevice: ChargedDeviceSummary?) -> [ChargeSessionKind: String] {
323
        guard let chargedDevice else {
324
            return [:]
325
        }
326

            
327
        return ChargeSessionKind.allCases.reduce(into: [ChargeSessionKind: String]()) { result, sessionKind in
328
            result[sessionKind] = optionalCurrentText(
329
                chargedDevice.configuredCompletionCurrentAmps(for: sessionKind)
330
            )
331
        }
Bogdan Timofte authored a month ago
332
    }
333

            
334
    private static func optionalCurrentText(_ value: Double?) -> String {
335
        guard let value else {
336
            return ""
337
        }
338
        return value.format(decimalDigits: 2)
339
    }
Bogdan Timofte authored a month ago
340

            
341
    private static func suggestedChargingStateAvailability(for deviceClass: ChargedDeviceClass) -> ChargingStateAvailability {
342
        switch deviceClass {
343
        case .iphone:
344
            return .onOrOff
345
        case .watch:
346
            return .onOnly
347
        case .powerbank:
348
            return .offOnly
349
        case .charger:
350
            return .onOnly
351
        case .other:
352
            return .onOnly
353
        }
354
    }
Bogdan Timofte authored a month ago
355
}