USB-Meter / USB Meter / Views / ChargedDevices / ChargedDeviceEditorSheetView.swift
Newer Older
394 lines | 14.108kb
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?
Bogdan Timofte authored a month ago
16
    let kind: ChargedDeviceKind
Bogdan Timofte authored a month ago
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 wirelessChargingProfile: WirelessChargingProfile
Bogdan Timofte authored a month ago
24
    @State private var completionCurrentTexts: [ChargeSessionKind: String]
Bogdan Timofte authored a month ago
25
    @State private var notes: String
26

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

            
Bogdan Timofte authored a month ago
35
        let resolvedKind = chargedDevice?.kind ?? kind
36
        self.kind = resolvedKind
37

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

            
51
    var body: some View {
52
        NavigationView {
53
            Form {
Bogdan Timofte authored a month ago
54
                identitySection
55

            
56
                if kind == .device {
57
                    deviceChargeBehaviourSection
58
                    deviceChargingSupportSection
59
                    deviceCompletionSection
60
                } else {
61
                    chargerInformationSection
Bogdan Timofte authored a month ago
62
                }
63

            
Bogdan Timofte authored a month ago
64
                notesSection
Bogdan Timofte authored a month ago
65
            }
66
            .navigationTitle(editorTitle)
67
            .navigationBarTitleDisplayMode(.inline)
68
            .toolbar {
69
                ToolbarItem(placement: .cancellationAction) {
70
                    Button("Cancel") {
71
                        dismiss()
72
                    }
73
                }
74
                ToolbarItem(placement: .confirmationAction) {
75
                    Button(saveButtonTitle) {
Bogdan Timofte authored a month ago
76
                        save()
Bogdan Timofte authored a month ago
77
                    }
Bogdan Timofte authored a month ago
78
                    .disabled(!canSave)
Bogdan Timofte authored a month ago
79
                }
80
            }
81
        }
82
        .navigationViewStyle(StackNavigationViewStyle())
83
        .onChange(of: deviceClass) { newValue in
Bogdan Timofte authored a month ago
84
            guard kind == .device else {
85
                return
86
            }
Bogdan Timofte authored a month ago
87
            applySuggestedChargingSupport(for: newValue)
88
        }
89
        .onAppear {
Bogdan Timofte authored a month ago
90
            guard kind == .device, chargedDevice == nil else {
Bogdan Timofte authored a month ago
91
                return
92
            }
93
            applySuggestedChargingSupport(for: deviceClass)
94
        }
95
    }
96

            
Bogdan Timofte authored a month ago
97
    private var identitySection: some View {
98
        Section(header: Text("Identity")) {
99
            TextField(kind == .charger ? "Charger name" : "Name", text: $name)
100

            
101
            if kind == .device {
102
                Picker("Class", selection: $deviceClass) {
103
                    ForEach(ChargedDeviceClass.deviceCases) { deviceClass in
104
                        Label(deviceClass.title, systemImage: deviceClass.symbolName)
105
                            .tag(deviceClass)
106
                    }
107
                }
108
            }
109

            
110
            if let chargedDevice {
111
                Text(chargedDevice.qrIdentifier)
112
                    .font(.caption.monospaced())
113
                    .foregroundColor(.secondary)
114
                    .textSelection(.enabled)
115
            }
116
        }
117
    }
118

            
119
    private var deviceChargeBehaviourSection: some View {
120
        Section(
121
            header: ContextInfoHeader(
122
                title: "Charge Behaviour",
123
                message: "Choose whether sessions for this device are recorded only while it is on, only while it is off, or explicitly in either state."
124
            )
125
        ) {
126
            Picker("Session Modes", selection: $chargingStateAvailability) {
127
                ForEach(ChargingStateAvailability.allCases) { availability in
128
                    Text(availability.title)
129
                        .tag(availability)
130
                }
131
            }
132
        }
133
    }
134

            
135
    private var deviceChargingSupportSection: some View {
136
        Section(
137
            header: ContextInfoHeader(
138
                title: "Charging Support",
139
                message: "Enable the charging methods this device actually supports. Wireless devices can also keep a dedicated profile so learning stays separate."
140
            )
141
        ) {
142
            Toggle("Supports wired charging", isOn: $supportsWiredCharging)
143
            Toggle("Supports wireless charging", isOn: $supportsWirelessCharging)
144

            
145
            if supportsWirelessCharging {
146
                Picker("Wireless profile", selection: $wirelessChargingProfile) {
147
                    ForEach(WirelessChargingProfile.allCases) { profile in
148
                        Text(profile.title)
149
                            .tag(profile)
150
                    }
151
                }
152

            
153
            }
154

            
155
            if supportedChargingModes.isEmpty {
156
                Text("Enable at least one charging method.")
157
                    .font(.footnote)
158
                    .foregroundColor(.secondary)
159
            }
160
        }
161
    }
162

            
163
    private var deviceCompletionSection: some View {
164
        Section(
165
            header: ContextInfoHeader(
166
                title: "Charge Completion",
167
                message: "Completion currents can be set per session type. Leave a value empty to keep learning that threshold automatically from sessions of the same type."
168
            )
169
        ) {
170
            if applicableSessionKinds.isEmpty {
171
                Text("Enable at least one charging method to configure stop currents.")
172
                    .font(.footnote)
173
                    .foregroundColor(.secondary)
174
            } else {
175
                ForEach(applicableSessionKinds) { sessionKind in
176
                    VStack(alignment: .leading, spacing: 6) {
177
                        TextField(
178
                            "\(sessionKind.shortTitle) completion current (A)",
179
                            text: completionCurrentTextBinding(for: sessionKind)
180
                        )
181
                        .keyboardType(.decimalPad)
182
                    }
183
                    .padding(.vertical, 2)
184
                }
185
            }
186
        }
187
    }
188

            
189
    private var chargerInformationSection: some View {
190
        Section(
191
            header: ContextInfoHeader(
192
                title: "Charger",
193
                message: "Chargers are edited separately from devices. Their charge-session metrics are learned automatically from wireless sessions."
194
            )
195
        ) {
196
            EmptyView()
197
        }
198
    }
199

            
200
    private var notesSection: some View {
201
        Section(header: Text("Notes")) {
202
            TextField("Optional notes", text: $notes)
203
        }
204
    }
205

            
Bogdan Timofte authored a month ago
206
    private var editorTitle: String {
207
        if chargedDevice == nil {
Bogdan Timofte authored a month ago
208
            return "New \(kind.title)"
Bogdan Timofte authored a month ago
209
        }
Bogdan Timofte authored a month ago
210
        return "Edit \(kind.title)"
Bogdan Timofte authored a month ago
211
    }
212

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

            
Bogdan Timofte authored a month ago
217
    private var canSave: Bool {
218
        let hasValidName = name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
219
        guard kind == .device else {
220
            return hasValidName
221
        }
222
        return hasValidName
223
            && (supportsWiredCharging || supportsWirelessCharging)
224
            && !hasInvalidCompletionCurrentEntry
225
    }
226

            
Bogdan Timofte authored a month ago
227
    private var supportedChargingModes: [ChargingTransportMode] {
228
        var modes: [ChargingTransportMode] = []
229
        if supportsWiredCharging {
230
            modes.append(.wired)
231
        }
232
        if supportsWirelessCharging {
233
            modes.append(.wireless)
234
        }
235
        return modes
236
    }
237

            
Bogdan Timofte authored a month ago
238
    private var applicableSessionKinds: [ChargeSessionKind] {
239
        supportedChargingModes.flatMap { chargingTransportMode in
240
            chargingStateAvailability.supportedModes.map { chargingStateMode in
241
                ChargeSessionKind(
242
                    chargingTransportMode: chargingTransportMode,
243
                    chargingStateMode: chargingStateMode
244
                )
245
            }
246
        }
247
    }
248

            
249
    private var parsedCompletionCurrents: [ChargeSessionKind: Double] {
250
        applicableSessionKinds.reduce(into: [ChargeSessionKind: Double]()) { result, sessionKind in
251
            guard let value = parsedOptionalCurrent(completionCurrentTexts[sessionKind] ?? "") else {
252
                return
253
            }
254
            result[sessionKind] = value
255
        }
256
    }
257

            
258
    private var hasInvalidCompletionCurrentEntry: Bool {
259
        applicableSessionKinds.contains { sessionKind in
260
            let text = completionCurrentTexts[sessionKind] ?? ""
261
            let normalized = text.trimmingCharacters(in: .whitespacesAndNewlines)
262
            return !normalized.isEmpty && parsedOptionalCurrent(text) == nil
263
        }
264
    }
265

            
266
    private func completionCurrentTextBinding(for sessionKind: ChargeSessionKind) -> Binding<String> {
267
        Binding(
268
            get: { completionCurrentTexts[sessionKind] ?? "" },
269
            set: { completionCurrentTexts[sessionKind] = $0 }
270
        )
271
    }
272

            
Bogdan Timofte authored a month ago
273
    private func save() {
274
        let didSave: Bool
275

            
276
        if kind == .charger {
277
            if let chargedDevice {
278
                didSave = appData.updateCharger(
279
                    id: chargedDevice.id,
280
                    name: name,
281
                    notes: notes
282
                )
283
            } else {
284
                didSave = appData.createCharger(
285
                    name: name,
286
                    notes: notes,
287
                    meterMACAddress: meterMACAddress
288
                )
289
            }
290
        } else {
291
            let configuredCompletionCurrents = parsedCompletionCurrents
292
            if let chargedDevice {
293
                didSave = appData.updateDevice(
294
                    id: chargedDevice.id,
295
                    name: name,
296
                    deviceClass: deviceClass,
297
                    chargingStateAvailability: chargingStateAvailability,
298
                    supportsWiredCharging: supportsWiredCharging,
299
                    supportsWirelessCharging: supportsWirelessCharging,
300
                    wirelessChargingProfile: wirelessChargingProfile,
301
                    configuredCompletionCurrents: configuredCompletionCurrents,
302
                    notes: notes
303
                )
304
            } else {
305
                didSave = appData.createDevice(
306
                    name: name,
307
                    deviceClass: deviceClass,
308
                    chargingStateAvailability: chargingStateAvailability,
309
                    supportsWiredCharging: supportsWiredCharging,
310
                    supportsWirelessCharging: supportsWirelessCharging,
311
                    wirelessChargingProfile: wirelessChargingProfile,
312
                    configuredCompletionCurrents: configuredCompletionCurrents,
313
                    notes: notes,
314
                    meterMACAddress: meterMACAddress
315
                )
316
            }
317
        }
318

            
319
        if didSave {
320
            dismiss()
321
        }
322
    }
323

            
Bogdan Timofte authored a month ago
324
    private func applySuggestedChargingSupport(for deviceClass: ChargedDeviceClass) {
Bogdan Timofte authored a month ago
325
        if chargedDevice != nil {
326
            return
327
        }
328

            
329
        chargingStateAvailability = Self.suggestedChargingStateAvailability(for: deviceClass)
330

            
Bogdan Timofte authored a month ago
331
        switch deviceClass {
332
        case .iphone:
333
            supportsWiredCharging = true
334
            supportsWirelessCharging = true
335
        case .watch:
336
            supportsWiredCharging = false
337
            supportsWirelessCharging = true
338
        case .powerbank:
339
            supportsWiredCharging = true
340
            supportsWirelessCharging = false
341
        case .charger:
Bogdan Timofte authored a month ago
342
            supportsWiredCharging = false
Bogdan Timofte authored a month ago
343
            supportsWirelessCharging = true
344
        case .other:
345
            supportsWiredCharging = true
346
            supportsWirelessCharging = false
347
        }
348
    }
349

            
350
    private func parsedOptionalCurrent(_ text: String) -> Double? {
351
        let normalized = text
352
            .trimmingCharacters(in: .whitespacesAndNewlines)
353
            .replacingOccurrences(of: ",", with: ".")
354
        guard !normalized.isEmpty else {
355
            return nil
356
        }
Bogdan Timofte authored a month ago
357
        guard let value = Double(normalized), value > 0 else {
358
            return nil
359
        }
360
        return value
361
    }
362

            
363
    private static func makeCompletionCurrentTexts(for chargedDevice: ChargedDeviceSummary?) -> [ChargeSessionKind: String] {
364
        guard let chargedDevice else {
365
            return [:]
366
        }
367

            
368
        return ChargeSessionKind.allCases.reduce(into: [ChargeSessionKind: String]()) { result, sessionKind in
369
            result[sessionKind] = optionalCurrentText(
370
                chargedDevice.configuredCompletionCurrentAmps(for: sessionKind)
371
            )
372
        }
Bogdan Timofte authored a month ago
373
    }
374

            
375
    private static func optionalCurrentText(_ value: Double?) -> String {
376
        guard let value else {
377
            return ""
378
        }
379
        return value.format(decimalDigits: 2)
380
    }
Bogdan Timofte authored a month ago
381

            
382
    private static func suggestedChargingStateAvailability(for deviceClass: ChargedDeviceClass) -> ChargingStateAvailability {
383
        switch deviceClass {
384
        case .iphone:
385
            return .onOrOff
386
        case .watch:
387
            return .onOnly
388
        case .powerbank:
389
            return .offOnly
Bogdan Timofte authored a month ago
390
        case .charger, .other:
Bogdan Timofte authored a month ago
391
            return .onOnly
392
        }
393
    }
Bogdan Timofte authored a month ago
394
}