@@ -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, |
@@ -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 |
+} |
|
@@ -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 |
} |
@@ -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) |