Showing 4 changed files with 327 additions and 75 deletions
+17 -0
USB Meter/Model/AppData.swift
@@ -41,6 +41,7 @@ final class AppData : ObservableObject {
41 41
     private var meterStoreCloudObserver: AnyCancellable?
42 42
     private var chargeInsightsStoreObserver: AnyCancellable?
43 43
     private var chargeInsightsRemoteObserver: AnyCancellable?
44
+    private var chargerStandbyPowerStoreObserver: AnyCancellable?
44 45
     private let meterStore = MeterNameStore.shared
45 46
     private var chargeInsightsStore: ChargeInsightsStore?
46 47
     private let chargerStandbyPowerStore = ChargerStandbyPowerStore()
@@ -60,6 +61,11 @@ final class AppData : ObservableObject {
60 61
             .sink { [weak self] _ in
61 62
                 self?.scheduleObjectWillChange()
62 63
             }
64
+        chargerStandbyPowerStoreObserver = NotificationCenter.default.publisher(for: .chargerStandbyPowerStoreDidChange)
65
+            .receive(on: DispatchQueue.main)
66
+            .sink { [weak self] _ in
67
+                self?.reloadChargedDevices()
68
+            }
63 69
     }
64 70
 
65 71
     let bluetoothManager = BluetoothManager()
@@ -287,6 +293,17 @@ final class AppData : ObservableObject {
287 293
         return didSave
288 294
     }
289 295
 
296
+    @discardableResult
297
+    func deleteChargerStandbyMeasurement(id: UUID, chargerID: UUID) -> Bool {
298
+        let didDelete = chargerStandbyPowerStore.removeMeasurement(id: id, chargerID: chargerID)
299
+        if didDelete {
300
+            reloadChargedDevices()
301
+        } else {
302
+            scheduleObjectWillChange()
303
+        }
304
+        return didDelete
305
+    }
306
+
290 307
     @discardableResult
291 308
     func createDevice(
292 309
         name: String,
+191 -5
USB Meter/Model/ChargerStandbyPowerStore.swift
@@ -12,10 +12,18 @@ final class ChargerStandbyPowerStore {
12 12
         var measurements: [ChargerStandbyPowerMeasurementSummary]
13 13
     }
14 14
 
15
+    private enum Keys {
16
+        static let cloudMeasurements = "ChargerStandbyPowerStore.measurements"
17
+    }
18
+
15 19
     private let fileManager: FileManager
16 20
     private let fileURL: URL
17 21
     private let encoder: JSONEncoder
18 22
     private let decoder: JSONDecoder
23
+    private let ubiquitousStore = NSUbiquitousKeyValueStore.default
24
+    private let workQueue = DispatchQueue(label: "ChargerStandbyPowerStore.Queue")
25
+    private var ubiquitousObserver: NSObjectProtocol?
26
+    private var ubiquityIdentityObserver: NSObjectProtocol?
19 27
 
20 28
     private var cachedMeasurements: [ChargerStandbyPowerMeasurementSummary]?
21 29
 
@@ -35,6 +43,25 @@ final class ChargerStandbyPowerStore {
35 43
 
36 44
         decoder = JSONDecoder()
37 45
         decoder.dateDecodingStrategy = .iso8601
46
+
47
+        ubiquitousObserver = NotificationCenter.default.addObserver(
48
+            forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification,
49
+            object: ubiquitousStore,
50
+            queue: nil
51
+        ) { [weak self] notification in
52
+            self?.handleUbiquitousStoreChange(notification)
53
+        }
54
+
55
+        ubiquityIdentityObserver = NotificationCenter.default.addObserver(
56
+            forName: NSNotification.Name.NSUbiquityIdentityDidChange,
57
+            object: nil,
58
+            queue: nil
59
+        ) { [weak self] _ in
60
+            self?.syncLocalValuesToCloudIfPossible(reason: "identity-changed")
61
+        }
62
+
63
+        ubiquitousStore.synchronize()
64
+        syncLocalValuesToCloudIfPossible(reason: "startup")
38 65
     }
39 66
 
40 67
     func measurementsByChargerID() -> [UUID: [ChargerStandbyPowerMeasurementSummary]] {
@@ -74,30 +101,85 @@ final class ChargerStandbyPowerStore {
74 101
         return persist(filteredMeasurements)
75 102
     }
76 103
 
104
+    @discardableResult
105
+    func removeMeasurement(id: UUID, chargerID: UUID? = nil) -> Bool {
106
+        let previousMeasurements = loadMeasurements()
107
+        let filteredMeasurements = previousMeasurements.filter { measurement in
108
+            guard measurement.id == id else {
109
+                return true
110
+            }
111
+
112
+            if let chargerID {
113
+                return measurement.chargerID != chargerID
114
+            }
115
+
116
+            return false
117
+        }
118
+
119
+        guard filteredMeasurements.count != previousMeasurements.count else {
120
+            return true
121
+        }
122
+
123
+        return persist(filteredMeasurements)
124
+    }
125
+
77 126
     private func loadMeasurements() -> [ChargerStandbyPowerMeasurementSummary] {
78 127
         if let cachedMeasurements {
79 128
             return cachedMeasurements
80 129
         }
81 130
 
131
+        let localMeasurements = loadLocalMeasurements()
132
+        let cloudMeasurements = loadCloudMeasurements()
133
+        let mergedMeasurements = merge(localMeasurements: localMeasurements, cloudMeasurements: cloudMeasurements)
134
+
135
+        cachedMeasurements = mergedMeasurements
136
+        return mergedMeasurements
137
+    }
138
+
139
+    private func loadLocalMeasurements() -> [ChargerStandbyPowerMeasurementSummary] {
82 140
         guard fileManager.fileExists(atPath: fileURL.path) else {
83
-            cachedMeasurements = []
84 141
             return []
85 142
         }
86
-
87 143
         do {
88 144
             let data = try Data(contentsOf: fileURL)
89 145
             let snapshot = try decoder.decode(Snapshot.self, from: data)
90
-            cachedMeasurements = snapshot.measurements
91 146
             return snapshot.measurements
92 147
         } catch {
93 148
             track("Failed to load charger standby power history: \(error.localizedDescription)")
94
-            cachedMeasurements = []
149
+            return []
150
+        }
151
+    }
152
+
153
+    private func loadCloudMeasurements() -> [ChargerStandbyPowerMeasurementSummary] {
154
+        guard isICloudDriveAvailable,
155
+              let data = ubiquitousStore.data(forKey: Keys.cloudMeasurements) else {
156
+            return []
157
+        }
158
+
159
+        do {
160
+            let snapshot = try decoder.decode(Snapshot.self, from: data)
161
+            return snapshot.measurements
162
+        } catch {
163
+            track("Failed to decode charger standby power history from iCloud KVS: \(error.localizedDescription)")
95 164
             return []
96 165
         }
97 166
     }
98 167
 
99 168
     @discardableResult
100 169
     private func persist(_ measurements: [ChargerStandbyPowerMeasurementSummary]) -> Bool {
170
+        let sortedMeasurements = sortMeasurements(measurements)
171
+        let didPersistLocal = persistLocally(sortedMeasurements)
172
+        let didPersistCloud = persistToCloudIfPossible(sortedMeasurements)
173
+
174
+        if didPersistLocal || didPersistCloud {
175
+            cachedMeasurements = sortedMeasurements
176
+        }
177
+
178
+        return didPersistLocal || didPersistCloud
179
+    }
180
+
181
+    @discardableResult
182
+    private func persistLocally(_ measurements: [ChargerStandbyPowerMeasurementSummary]) -> Bool {
101 183
         do {
102 184
             try fileManager.createDirectory(
103 185
                 at: fileURL.deletingLastPathComponent(),
@@ -107,13 +189,113 @@ final class ChargerStandbyPowerStore {
107 189
             let snapshot = Snapshot(measurements: measurements)
108 190
             let data = try encoder.encode(snapshot)
109 191
             try data.write(to: fileURL, options: .atomic)
110
-            cachedMeasurements = measurements
111 192
             return true
112 193
         } catch {
113 194
             track("Failed to save charger standby power history: \(error.localizedDescription)")
114 195
             return false
115 196
         }
116 197
     }
198
+
199
+    @discardableResult
200
+    private func persistToCloudIfPossible(_ measurements: [ChargerStandbyPowerMeasurementSummary]) -> Bool {
201
+        guard isICloudDriveAvailable else {
202
+            return false
203
+        }
204
+
205
+        do {
206
+            let snapshot = Snapshot(measurements: measurements)
207
+            let data = try encoder.encode(snapshot)
208
+            ubiquitousStore.set(data, forKey: Keys.cloudMeasurements)
209
+            ubiquitousStore.synchronize()
210
+            return true
211
+        } catch {
212
+            track("Failed to encode charger standby power history for iCloud KVS: \(error.localizedDescription)")
213
+            return false
214
+        }
215
+    }
216
+
217
+    private func merge(
218
+        localMeasurements: [ChargerStandbyPowerMeasurementSummary],
219
+        cloudMeasurements: [ChargerStandbyPowerMeasurementSummary]
220
+    ) -> [ChargerStandbyPowerMeasurementSummary] {
221
+        var mergedByID: [UUID: ChargerStandbyPowerMeasurementSummary] = [:]
222
+
223
+        for measurement in localMeasurements {
224
+            mergedByID[measurement.id] = measurement
225
+        }
226
+
227
+        for measurement in cloudMeasurements {
228
+            mergedByID[measurement.id] = measurement
229
+        }
230
+
231
+        return sortMeasurements(Array(mergedByID.values))
232
+    }
233
+
234
+    private func sortMeasurements(_ measurements: [ChargerStandbyPowerMeasurementSummary]) -> [ChargerStandbyPowerMeasurementSummary] {
235
+        measurements.sorted { lhs, rhs in
236
+            if lhs.endedAt != rhs.endedAt {
237
+                return lhs.endedAt > rhs.endedAt
238
+            }
239
+            return lhs.id.uuidString > rhs.id.uuidString
240
+        }
241
+    }
242
+
243
+    private var isICloudDriveAvailable: Bool {
244
+        FileManager.default.ubiquityIdentityToken != nil
245
+    }
246
+
247
+    private func handleUbiquitousStoreChange(_ notification: Notification) {
248
+        if let changedKeys = notification.userInfo?[NSUbiquitousKeyValueStoreChangedKeysKey] as? [String],
249
+           changedKeys.contains(Keys.cloudMeasurements) == false {
250
+            return
251
+        }
252
+
253
+        workQueue.async { [weak self] in
254
+            guard let self else { return }
255
+            let mergedMeasurements = self.merge(
256
+                localMeasurements: self.loadLocalMeasurements(),
257
+                cloudMeasurements: self.loadCloudMeasurements()
258
+            )
259
+            self.cachedMeasurements = mergedMeasurements
260
+            _ = self.persistLocally(mergedMeasurements)
261
+            DispatchQueue.main.async {
262
+                NotificationCenter.default.post(name: .chargerStandbyPowerStoreDidChange, object: nil)
263
+            }
264
+        }
265
+    }
266
+
267
+    private func syncLocalValuesToCloudIfPossible(reason: String) {
268
+        guard isICloudDriveAvailable else {
269
+            return
270
+        }
271
+
272
+        workQueue.async { [weak self] in
273
+            guard let self else { return }
274
+            let mergedMeasurements = self.merge(
275
+                localMeasurements: self.loadLocalMeasurements(),
276
+                cloudMeasurements: self.loadCloudMeasurements()
277
+            )
278
+            let didPersistLocal = self.persistLocally(mergedMeasurements)
279
+            let didPersistCloud = self.persistToCloudIfPossible(mergedMeasurements)
280
+            self.cachedMeasurements = mergedMeasurements
281
+
282
+            if didPersistLocal || didPersistCloud {
283
+                track("ChargerStandbyPowerStore synchronized standby measurements with iCloud KVS (\(reason)).")
284
+                DispatchQueue.main.async {
285
+                    NotificationCenter.default.post(name: .chargerStandbyPowerStoreDidChange, object: nil)
286
+                }
287
+            }
288
+        }
289
+    }
290
+
291
+    deinit {
292
+        if let observer = ubiquitousObserver {
293
+            NotificationCenter.default.removeObserver(observer)
294
+        }
295
+        if let observer = ubiquityIdentityObserver {
296
+            NotificationCenter.default.removeObserver(observer)
297
+        }
298
+    }
117 299
 }
118 300
 
119 301
 final class ChargerStandbyPowerMonitorSession: ObservableObject, Identifiable {
@@ -272,3 +454,7 @@ final class ChargerStandbyPowerMonitorSession: ObservableObject, Identifiable {
272 454
         }
273 455
     }
274 456
 }
457
+
458
+extension Notification.Name {
459
+    static let chargerStandbyPowerStoreDidChange = Notification.Name("ChargerStandbyPowerStoreDidChange")
460
+}
+0 -34
USB Meter/Views/ChargedDevices/ChargedDeviceDetailView.swift
@@ -358,40 +358,6 @@ struct ChargedDeviceDetailView: View {
358 358
                         .foregroundColor(.blue)
359 359
                 }
360 360
                 .buttonStyle(.plain)
361
-
362
-                Divider()
363
-
364
-                ForEach(Array(chargedDevice.standbyPowerMeasurements.prefix(3))) { measurement in
365
-                    NavigationLink(
366
-                        destination: ChargerStandbyPowerMeasurementDetailView(
367
-                            chargerID: chargedDevice.id,
368
-                            measurementID: measurement.id
369
-                        )
370
-                    ) {
371
-                        HStack {
372
-                            VStack(alignment: .leading, spacing: 4) {
373
-                                Text(measurement.endedAt.format())
374
-                                    .font(.subheadline.weight(.semibold))
375
-                                    .foregroundColor(.primary)
376
-                                Text("\(measurement.sampleCount) samples")
377
-                                    .font(.caption)
378
-                                    .foregroundColor(.secondary)
379
-                            }
380
-
381
-                            Spacer()
382
-
383
-                            Text("\(measurement.averagePowerWatts.format(decimalDigits: 3)) W")
384
-                                .font(.subheadline.weight(.bold))
385
-                                .foregroundColor(.primary)
386
-                                .monospacedDigit()
387
-                        }
388
-                    }
389
-                    .buttonStyle(.plain)
390
-
391
-                    if measurement.id != chargedDevice.standbyPowerMeasurements.prefix(3).last?.id {
392
-                        Divider()
393
-                    }
394
-                }
395 361
             }
396 362
         }
397 363
     }
+119 -36
USB Meter/Views/Meter/Tabs/Live/ChargerStandbyPowerWizardView.swift
@@ -453,9 +453,12 @@ struct ChargerStandbyPowerWizardView: View {
453 453
             .frame(height: 220)
454 454
 
455 455
             if let firstBin = histogram.first, let lastBin = histogram.last {
456
+                let midpointWatts = (firstBin.lowerBoundWatts + lastBin.upperBoundWatts) / 2
456 457
                 HStack {
457 458
                     Text("\(firstBin.lowerBoundWatts.format(decimalDigits: 3)) W")
458 459
                     Spacer()
460
+                    Text("\(midpointWatts.format(decimalDigits: 3)) W")
461
+                    Spacer()
459 462
                     Text("\(lastBin.upperBoundWatts.format(decimalDigits: 3)) W")
460 463
                 }
461 464
                 .font(.caption)
@@ -600,46 +603,14 @@ private struct StandbyPowerHistogramView: View {
600 603
 
601 604
 struct ChargerStandbyPowerMeasurementsView: View {
602 605
     @EnvironmentObject private var appData: AppData
606
+    @State private var selectedMeasurementIDs = Set<UUID>()
603 607
 
604 608
     let chargerID: UUID
605 609
 
606 610
     var body: some View {
607 611
         Group {
608 612
             if let charger = appData.chargedDeviceSummary(id: chargerID) {
609
-                List {
610
-                    if charger.standbyPowerMeasurements.isEmpty {
611
-                        Text("No standby measurements saved yet.")
612
-                            .foregroundColor(.secondary)
613
-                    } else {
614
-                        ForEach(charger.standbyPowerMeasurements) { measurement in
615
-                            NavigationLink(
616
-                                destination: ChargerStandbyPowerMeasurementDetailView(
617
-                                    chargerID: charger.id,
618
-                                    measurementID: measurement.id
619
-                                )
620
-                            ) {
621
-                                VStack(alignment: .leading, spacing: 6) {
622
-                                    HStack {
623
-                                        Text(measurement.endedAt.format())
624
-                                            .font(.subheadline.weight(.semibold))
625
-                                        Spacer()
626
-                                        Text("\(measurement.averagePowerWatts.format(decimalDigits: 3)) W")
627
-                                            .font(.subheadline.weight(.bold))
628
-                                            .monospacedDigit()
629
-                                    }
630
-
631
-                                    Text(
632
-                                        "\(formattedDuration(measurement.duration)) • \(measurement.sampleCount) samples • \(standbyEnergyLabel(measurement.projectedYearlyEnergyWh)) / year"
633
-                                    )
634
-                                    .font(.caption)
635
-                                    .foregroundColor(.secondary)
636
-                                }
637
-                                .frame(maxWidth: .infinity, alignment: .leading)
638
-                            }
639
-                        }
640
-                    }
641
-                }
642
-                .navigationTitle("Saved Measurements")
613
+                measurementsList(for: charger)
643 614
             } else {
644 615
                 Text("This charger is no longer available.")
645 616
                     .foregroundColor(.secondary)
@@ -648,6 +619,78 @@ struct ChargerStandbyPowerMeasurementsView: View {
648 619
         }
649 620
     }
650 621
 
622
+    @ViewBuilder
623
+    private func measurementsList(for charger: ChargedDeviceSummary) -> some View {
624
+        let content = List(selection: $selectedMeasurementIDs) {
625
+            if charger.standbyPowerMeasurements.isEmpty {
626
+                Text("No standby measurements saved yet.")
627
+                    .foregroundColor(.secondary)
628
+            } else {
629
+                ForEach(charger.standbyPowerMeasurements) { measurement in
630
+                    NavigationLink(
631
+                        destination: ChargerStandbyPowerMeasurementDetailView(
632
+                            chargerID: charger.id,
633
+                            measurementID: measurement.id
634
+                        )
635
+                    ) {
636
+                        VStack(alignment: .leading, spacing: 6) {
637
+                            HStack {
638
+                                Text(measurement.endedAt.format())
639
+                                    .font(.subheadline.weight(.semibold))
640
+                                Spacer()
641
+                                Text("\(measurement.averagePowerWatts.format(decimalDigits: 3)) W")
642
+                                    .font(.subheadline.weight(.bold))
643
+                                    .monospacedDigit()
644
+                            }
645
+
646
+                            Text(
647
+                                "\(formattedDuration(measurement.duration)) • \(measurement.sampleCount) samples • \(standbyEnergyLabel(measurement.projectedYearlyEnergyWh)) / year"
648
+                            )
649
+                            .font(.caption)
650
+                            .foregroundColor(.secondary)
651
+                        }
652
+                        .frame(maxWidth: .infinity, alignment: .leading)
653
+                    }
654
+                    .tag(measurement.id)
655
+                }
656
+                .onDelete { offsets in
657
+                    let measurements = charger.standbyPowerMeasurements
658
+                    for index in offsets {
659
+                        guard measurements.indices.contains(index) else { continue }
660
+                        let measurement = measurements[index]
661
+                        _ = appData.deleteChargerStandbyMeasurement(
662
+                            id: measurement.id,
663
+                            chargerID: charger.id
664
+                        )
665
+                    }
666
+                }
667
+            }
668
+        }
669
+        .navigationTitle("Saved Measurements")
670
+        .toolbar {
671
+            ToolbarItem(placement: .primaryAction) {
672
+                EditButton()
673
+            }
674
+        }
675
+
676
+        if selectedMeasurementIDs.isEmpty {
677
+            content
678
+        } else {
679
+            content.toolbar {
680
+                ToolbarItem(placement: .destructiveAction) {
681
+                    Button(role: .destructive) {
682
+                        deleteMeasurements(
683
+                            ids: selectedMeasurementIDs,
684
+                            for: charger.id
685
+                        )
686
+                    } label: {
687
+                        Image(systemName: "trash")
688
+                    }
689
+                }
690
+            }
691
+        }
692
+    }
693
+
651 694
     private func standbyEnergyLabel(_ wattHours: Double) -> String {
652 695
         if wattHours >= 1000 {
653 696
             return "\((wattHours / 1000).format(decimalDigits: 3)) kWh"
@@ -662,10 +705,20 @@ struct ChargerStandbyPowerMeasurementsView: View {
662 705
         formatter.zeroFormattingBehavior = .pad
663 706
         return formatter.string(from: max(duration, 0)) ?? "0s"
664 707
     }
708
+
709
+    private func deleteMeasurements(ids: Set<UUID>, for chargerID: UUID) {
710
+        for id in ids {
711
+            _ = appData.deleteChargerStandbyMeasurement(id: id, chargerID: chargerID)
712
+        }
713
+        selectedMeasurementIDs.removeAll()
714
+    }
665 715
 }
666 716
 
667 717
 struct ChargerStandbyPowerMeasurementDetailView: View {
668 718
     @EnvironmentObject private var appData: AppData
719
+    @Environment(\.dismiss) private var dismiss
720
+
721
+    @State private var deleteConfirmationVisibility = false
669 722
 
670 723
     let chargerID: UUID
671 724
     let measurementID: UUID
@@ -692,10 +745,37 @@ struct ChargerStandbyPowerMeasurementDetailView: View {
692 745
                         colors: [.orange.opacity(0.16), Color.clear],
693 746
                         startPoint: .topLeading,
694 747
                         endPoint: .bottomTrailing
695
-                    )
696
-                    .ignoresSafeArea()
748
+                )
749
+                .ignoresSafeArea()
697 750
                 )
698 751
                 .navigationTitle("Measurement")
752
+                .toolbar {
753
+                    ToolbarItem(placement: .primaryAction) {
754
+                        Button(role: .destructive) {
755
+                            deleteConfirmationVisibility = true
756
+                        } label: {
757
+                            Label("Delete Measurement", systemImage: "trash")
758
+                        }
759
+                    }
760
+                }
761
+                .confirmationDialog(
762
+                    "Delete this measurement?",
763
+                    isPresented: $deleteConfirmationVisibility,
764
+                    titleVisibility: .visible
765
+                ) {
766
+                    Button("Delete", role: .destructive) {
767
+                        let didDelete = appData.deleteChargerStandbyMeasurement(
768
+                            id: measurement.id,
769
+                            chargerID: charger.id
770
+                        )
771
+                        if didDelete {
772
+                            dismiss()
773
+                        }
774
+                    }
775
+                    Button("Cancel", role: .cancel) {}
776
+                } message: {
777
+                    Text("This removes the saved standby measurement from the charger history and iCloud sync.")
778
+                }
699 779
             } else {
700 780
                 Text("This measurement is no longer available.")
701 781
                     .foregroundColor(.secondary)
@@ -799,9 +879,12 @@ private struct ChargerStandbyPowerMeasurementSnapshotView: View {
799 879
             .frame(height: 220)
800 880
 
801 881
             if let firstBin = measurement.histogram.first, let lastBin = measurement.histogram.last {
882
+                let midpointWatts = (firstBin.lowerBoundWatts + lastBin.upperBoundWatts) / 2
802 883
                 HStack {
803 884
                     Text("\(firstBin.lowerBoundWatts.format(decimalDigits: 3)) W")
804 885
                     Spacer()
886
+                    Text("\(midpointWatts.format(decimalDigits: 3)) W")
887
+                    Spacer()
805 888
                     Text("\(lastBin.upperBoundWatts.format(decimalDigits: 3)) W")
806 889
                 }
807 890
                 .font(.caption)