Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@@ -203,29 +203,14 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD |
||
| 203 | 203 |
|
| 204 | 204 |
container.loadPersistentStores { storeDescription, error in
|
| 205 | 205 |
if let error = error as NSError? {
|
| 206 |
- NSLog("Core Data store load failed: %@", error.localizedDescription)
|
|
| 207 |
- |
|
| 208 |
- if let storeURL = storeDescription.url {
|
|
| 209 |
- let coordinator = container.persistentStoreCoordinator |
|
| 210 |
- do {
|
|
| 211 |
- try coordinator.destroyPersistentStore( |
|
| 212 |
- at: storeURL, |
|
| 213 |
- ofType: storeDescription.type, |
|
| 214 |
- options: nil |
|
| 215 |
- ) |
|
| 216 |
- try coordinator.addPersistentStore( |
|
| 217 |
- ofType: storeDescription.type, |
|
| 218 |
- configurationName: nil, |
|
| 219 |
- at: storeURL, |
|
| 220 |
- options: storeDescription.options |
|
| 221 |
- ) |
|
| 222 |
- NSLog("Recovered CloudKit store by recreating it at %@", storeURL.path)
|
|
| 223 |
- return |
|
| 224 |
- } catch {
|
|
| 225 |
- NSLog("Core Data recovery attempt failed: %@", error.localizedDescription)
|
|
| 226 |
- } |
|
| 227 |
- } |
|
| 228 |
- |
|
| 206 |
+ // Log the error but do NOT destroy the store — wiping local data and |
|
| 207 |
+ // waiting for a full CloudKit re-sync is far worse than a degraded launch. |
|
| 208 |
+ NSLog( |
|
| 209 |
+ "Core Data store load failed (url=%@): %@ — %@", |
|
| 210 |
+ storeDescription.url?.path ?? "unknown", |
|
| 211 |
+ error.localizedDescription, |
|
| 212 |
+ error.userInfo |
|
| 213 |
+ ) |
|
| 229 | 214 |
#if DEBUG |
| 230 | 215 |
fatalError("Unresolved Core Data error \(error), \(error.userInfo)")
|
| 231 | 216 |
#endif |
@@ -748,7 +748,7 @@ struct ChargerStandbyPowerSample: Identifiable, Hashable, Codable {
|
||
| 748 | 748 |
} |
| 749 | 749 |
} |
| 750 | 750 |
|
| 751 |
-struct ChargerStandbyPowerDistributionBin: Identifiable, Hashable {
|
|
| 751 |
+struct ChargerStandbyPowerDistributionBin: Identifiable, Hashable, Codable {
|
|
| 752 | 752 |
let index: Int |
| 753 | 753 |
let lowerBoundWatts: Double |
| 754 | 754 |
let upperBoundWatts: Double |
@@ -758,6 +758,22 @@ struct ChargerStandbyPowerDistributionBin: Identifiable, Hashable {
|
||
| 758 | 758 |
var id: Int { index }
|
| 759 | 759 |
} |
| 760 | 760 |
|
| 761 |
+enum HistogramResolution: Int, CaseIterable, Identifiable {
|
|
| 762 |
+ case x1 = 1 |
|
| 763 |
+ case x2 = 2 |
|
| 764 |
+ case x4 = 4 |
|
| 765 |
+ |
|
| 766 |
+ var id: Int { rawValue }
|
|
| 767 |
+ |
|
| 768 |
+ var label: String {
|
|
| 769 |
+ switch self {
|
|
| 770 |
+ case .x1: return "1×" |
|
| 771 |
+ case .x2: return "2×" |
|
| 772 |
+ case .x4: return "4×" |
|
| 773 |
+ } |
|
| 774 |
+ } |
|
| 775 |
+} |
|
| 776 |
+ |
|
| 761 | 777 |
struct ChargerStandbyPowerMeasurementStatistics: Hashable {
|
| 762 | 778 |
let sampleCount: Int |
| 763 | 779 |
let observedDuration: TimeInterval |
@@ -821,42 +837,129 @@ struct ChargerStandbyPowerMeasurementSummary: Identifiable, Hashable, Codable {
|
||
| 821 | 837 |
let averageVoltageVolts: Double |
| 822 | 838 |
let stabilityDeltaWatts: Double |
| 823 | 839 |
let stabilityToleranceWatts: Double |
| 824 |
- let powerSamplesWatts: [Double] |
|
| 825 |
- |
|
| 826 |
- var duration: TimeInterval {
|
|
| 827 |
- endedAt.timeIntervalSince(startedAt) |
|
| 828 |
- } |
|
| 829 |
- |
|
| 830 |
- var projectedDailyEnergyWh: Double {
|
|
| 831 |
- averagePowerWatts * 24 |
|
| 832 |
- } |
|
| 833 |
- |
|
| 834 |
- var projectedWeeklyEnergyWh: Double {
|
|
| 835 |
- averagePowerWatts * 24 * 7 |
|
| 836 |
- } |
|
| 837 |
- |
|
| 838 |
- var projectedMonthlyEnergyWh: Double {
|
|
| 839 |
- averagePowerWatts * 24 * 30 |
|
| 840 |
- } |
|
| 841 |
- |
|
| 842 |
- var projectedYearlyEnergyWh: Double {
|
|
| 843 |
- averagePowerWatts * 24 * 365 |
|
| 844 |
- } |
|
| 845 |
- |
|
| 846 |
- var isStable: Bool {
|
|
| 847 |
- stabilizedAt != nil |
|
| 840 |
+ /// Histogram stored at 4× resolution. Use `histogram(resolution:)` to downsample for display. |
|
| 841 |
+ let storedHistogram: [ChargerStandbyPowerDistributionBin] |
|
| 842 |
+ |
|
| 843 |
+ // MARK: - Codable (with migration from legacy powerSamplesWatts) |
|
| 844 |
+ |
|
| 845 |
+ private enum CodingKeys: String, CodingKey {
|
|
| 846 |
+ case id, chargerID, meterMACAddress, meterName, meterModel |
|
| 847 |
+ case startedAt, endedAt, sampleCount, stabilizedAt |
|
| 848 |
+ case averagePowerWatts, recentAveragePowerWatts, medianPowerWatts |
|
| 849 |
+ case minimumPowerWatts, maximumPowerWatts, standardDeviationPowerWatts |
|
| 850 |
+ case coefficientOfVariation, averageCurrentAmps, averageVoltageVolts |
|
| 851 |
+ case stabilityDeltaWatts, stabilityToleranceWatts |
|
| 852 |
+ case storedHistogram |
|
| 853 |
+ case powerSamplesWatts // legacy – decode only |
|
| 854 |
+ } |
|
| 855 |
+ |
|
| 856 |
+ init( |
|
| 857 |
+ id: UUID, chargerID: UUID, meterMACAddress: String, meterName: String?, meterModel: String?, |
|
| 858 |
+ startedAt: Date, endedAt: Date, sampleCount: Int, stabilizedAt: Date?, |
|
| 859 |
+ averagePowerWatts: Double, recentAveragePowerWatts: Double, medianPowerWatts: Double, |
|
| 860 |
+ minimumPowerWatts: Double, maximumPowerWatts: Double, standardDeviationPowerWatts: Double, |
|
| 861 |
+ coefficientOfVariation: Double, averageCurrentAmps: Double, averageVoltageVolts: Double, |
|
| 862 |
+ stabilityDeltaWatts: Double, stabilityToleranceWatts: Double, |
|
| 863 |
+ storedHistogram: [ChargerStandbyPowerDistributionBin] |
|
| 864 |
+ ) {
|
|
| 865 |
+ self.id = id; self.chargerID = chargerID; self.meterMACAddress = meterMACAddress |
|
| 866 |
+ self.meterName = meterName; self.meterModel = meterModel |
|
| 867 |
+ self.startedAt = startedAt; self.endedAt = endedAt; self.sampleCount = sampleCount |
|
| 868 |
+ self.stabilizedAt = stabilizedAt |
|
| 869 |
+ self.averagePowerWatts = averagePowerWatts; self.recentAveragePowerWatts = recentAveragePowerWatts |
|
| 870 |
+ self.medianPowerWatts = medianPowerWatts; self.minimumPowerWatts = minimumPowerWatts |
|
| 871 |
+ self.maximumPowerWatts = maximumPowerWatts |
|
| 872 |
+ self.standardDeviationPowerWatts = standardDeviationPowerWatts |
|
| 873 |
+ self.coefficientOfVariation = coefficientOfVariation |
|
| 874 |
+ self.averageCurrentAmps = averageCurrentAmps; self.averageVoltageVolts = averageVoltageVolts |
|
| 875 |
+ self.stabilityDeltaWatts = stabilityDeltaWatts; self.stabilityToleranceWatts = stabilityToleranceWatts |
|
| 876 |
+ self.storedHistogram = storedHistogram |
|
| 877 |
+ } |
|
| 878 |
+ |
|
| 879 |
+ init(from decoder: Decoder) throws {
|
|
| 880 |
+ let c = try decoder.container(keyedBy: CodingKeys.self) |
|
| 881 |
+ id = try c.decode(UUID.self, forKey: .id) |
|
| 882 |
+ chargerID = try c.decode(UUID.self, forKey: .chargerID) |
|
| 883 |
+ meterMACAddress = try c.decode(String.self, forKey: .meterMACAddress) |
|
| 884 |
+ meterName = try c.decodeIfPresent(String.self, forKey: .meterName) |
|
| 885 |
+ meterModel = try c.decodeIfPresent(String.self, forKey: .meterModel) |
|
| 886 |
+ startedAt = try c.decode(Date.self, forKey: .startedAt) |
|
| 887 |
+ endedAt = try c.decode(Date.self, forKey: .endedAt) |
|
| 888 |
+ sampleCount = try c.decode(Int.self, forKey: .sampleCount) |
|
| 889 |
+ stabilizedAt = try c.decodeIfPresent(Date.self, forKey: .stabilizedAt) |
|
| 890 |
+ averagePowerWatts = try c.decode(Double.self, forKey: .averagePowerWatts) |
|
| 891 |
+ recentAveragePowerWatts = try c.decode(Double.self, forKey: .recentAveragePowerWatts) |
|
| 892 |
+ medianPowerWatts = try c.decode(Double.self, forKey: .medianPowerWatts) |
|
| 893 |
+ minimumPowerWatts = try c.decode(Double.self, forKey: .minimumPowerWatts) |
|
| 894 |
+ maximumPowerWatts = try c.decode(Double.self, forKey: .maximumPowerWatts) |
|
| 895 |
+ standardDeviationPowerWatts = try c.decode(Double.self, forKey: .standardDeviationPowerWatts) |
|
| 896 |
+ coefficientOfVariation = try c.decode(Double.self, forKey: .coefficientOfVariation) |
|
| 897 |
+ averageCurrentAmps = try c.decode(Double.self, forKey: .averageCurrentAmps) |
|
| 898 |
+ averageVoltageVolts = try c.decode(Double.self, forKey: .averageVoltageVolts) |
|
| 899 |
+ stabilityDeltaWatts = try c.decode(Double.self, forKey: .stabilityDeltaWatts) |
|
| 900 |
+ stabilityToleranceWatts = try c.decode(Double.self, forKey: .stabilityToleranceWatts) |
|
| 901 |
+ |
|
| 902 |
+ let decodedBins = try? c.decodeIfPresent([ChargerStandbyPowerDistributionBin].self, forKey: .storedHistogram) |
|
| 903 |
+ if let decodedBins, !decodedBins.isEmpty {
|
|
| 904 |
+ storedHistogram = decodedBins |
|
| 905 |
+ } else {
|
|
| 906 |
+ // Migrate from legacy raw samples format |
|
| 907 |
+ let samples = (try? c.decodeIfPresent([Double].self, forKey: .powerSamplesWatts)) ?? [] |
|
| 908 |
+ let base = min(18, max(8, Int(Double(samples.count).squareRoot().rounded()))) |
|
| 909 |
+ storedHistogram = ChargerStandbyPowerMeasurementAnalyzer.histogram( |
|
| 910 |
+ for: samples, |
|
| 911 |
+ preferredBinCount: base * HistogramResolution.x4.rawValue |
|
| 912 |
+ ) |
|
| 913 |
+ } |
|
| 848 | 914 |
} |
| 849 | 915 |
|
| 850 |
- var histogram: [ChargerStandbyPowerDistributionBin] {
|
|
| 851 |
- ChargerStandbyPowerMeasurementAnalyzer.histogram(for: powerSamplesWatts) |
|
| 916 |
+ func encode(to encoder: Encoder) throws {
|
|
| 917 |
+ var c = encoder.container(keyedBy: CodingKeys.self) |
|
| 918 |
+ try c.encode(id, forKey: .id) |
|
| 919 |
+ try c.encode(chargerID, forKey: .chargerID) |
|
| 920 |
+ try c.encode(meterMACAddress, forKey: .meterMACAddress) |
|
| 921 |
+ try c.encodeIfPresent(meterName, forKey: .meterName) |
|
| 922 |
+ try c.encodeIfPresent(meterModel, forKey: .meterModel) |
|
| 923 |
+ try c.encode(startedAt, forKey: .startedAt) |
|
| 924 |
+ try c.encode(endedAt, forKey: .endedAt) |
|
| 925 |
+ try c.encode(sampleCount, forKey: .sampleCount) |
|
| 926 |
+ try c.encodeIfPresent(stabilizedAt, forKey: .stabilizedAt) |
|
| 927 |
+ try c.encode(averagePowerWatts, forKey: .averagePowerWatts) |
|
| 928 |
+ try c.encode(recentAveragePowerWatts, forKey: .recentAveragePowerWatts) |
|
| 929 |
+ try c.encode(medianPowerWatts, forKey: .medianPowerWatts) |
|
| 930 |
+ try c.encode(minimumPowerWatts, forKey: .minimumPowerWatts) |
|
| 931 |
+ try c.encode(maximumPowerWatts, forKey: .maximumPowerWatts) |
|
| 932 |
+ try c.encode(standardDeviationPowerWatts, forKey: .standardDeviationPowerWatts) |
|
| 933 |
+ try c.encode(coefficientOfVariation, forKey: .coefficientOfVariation) |
|
| 934 |
+ try c.encode(averageCurrentAmps, forKey: .averageCurrentAmps) |
|
| 935 |
+ try c.encode(averageVoltageVolts, forKey: .averageVoltageVolts) |
|
| 936 |
+ try c.encode(stabilityDeltaWatts, forKey: .stabilityDeltaWatts) |
|
| 937 |
+ try c.encode(stabilityToleranceWatts, forKey: .stabilityToleranceWatts) |
|
| 938 |
+ try c.encode(storedHistogram, forKey: .storedHistogram) |
|
| 939 |
+ } |
|
| 940 |
+ |
|
| 941 |
+ // MARK: - Computed |
|
| 942 |
+ |
|
| 943 |
+ var duration: TimeInterval { endedAt.timeIntervalSince(startedAt) }
|
|
| 944 |
+ var projectedDailyEnergyWh: Double { averagePowerWatts * 24 }
|
|
| 945 |
+ var projectedWeeklyEnergyWh: Double { averagePowerWatts * 24 * 7 }
|
|
| 946 |
+ var projectedMonthlyEnergyWh: Double { averagePowerWatts * 24 * 30 }
|
|
| 947 |
+ var projectedYearlyEnergyWh: Double { averagePowerWatts * 24 * 365 }
|
|
| 948 |
+ var isStable: Bool { stabilizedAt != nil }
|
|
| 949 |
+ |
|
| 950 |
+ /// Returns the histogram downsampled to the requested resolution. |
|
| 951 |
+ /// The stored histogram is always at 4× so factor = 4 / resolution.rawValue. |
|
| 952 |
+ func histogram(resolution: HistogramResolution = .x4) -> [ChargerStandbyPowerDistributionBin] {
|
|
| 953 |
+ let factor = HistogramResolution.x4.rawValue / resolution.rawValue |
|
| 954 |
+ return ChargerStandbyPowerMeasurementAnalyzer.downsample(storedHistogram, factor: factor) |
|
| 852 | 955 |
} |
| 853 | 956 |
} |
| 854 | 957 |
|
| 855 | 958 |
enum ChargerStandbyPowerMeasurementAnalyzer {
|
| 856 | 959 |
static let minimumStableSampleCount = 45 |
| 857 |
- static let recentSampleWindow = 20 |
|
| 858 |
- static let minimumStabilityToleranceWatts = 0.003 |
|
| 859 |
- static let relativeStabilityTolerance = 0.01 |
|
| 960 |
+ static let recentSampleWindow = 40 |
|
| 961 |
+ static let minimumStabilityToleranceWatts = 0.010 |
|
| 962 |
+ static let relativeStabilityTolerance = 0.05 |
|
| 860 | 963 |
|
| 861 | 964 |
static func statistics( |
| 862 | 965 |
from samples: [ChargerStandbyPowerSample], |
@@ -884,6 +987,9 @@ enum ChargerStandbyPowerMeasurementAnalyzer {
|
||
| 884 | 987 |
abs(averagePower) * relativeStabilityTolerance |
| 885 | 988 |
) |
| 886 | 989 |
|
| 990 |
+ let baseBinCount = min(18, max(8, Int(Double(powerValues.count).squareRoot().rounded()))) |
|
| 991 |
+ let liveHistogram = histogram(for: powerValues, preferredBinCount: baseBinCount * HistogramResolution.x4.rawValue) |
|
| 992 |
+ |
|
| 887 | 993 |
return ChargerStandbyPowerMeasurementStatistics( |
| 888 | 994 |
sampleCount: powerValues.count, |
| 889 | 995 |
observedDuration: max(referenceDate.timeIntervalSince(startedAt), 0), |
@@ -898,7 +1004,7 @@ enum ChargerStandbyPowerMeasurementAnalyzer {
|
||
| 898 | 1004 |
averageVoltageVolts: mean(voltageValues), |
| 899 | 1005 |
stabilityDeltaWatts: stabilityDelta, |
| 900 | 1006 |
stabilityToleranceWatts: stabilityTolerance, |
| 901 |
- histogram: histogram(for: powerValues) |
|
| 1007 |
+ histogram: liveHistogram |
|
| 902 | 1008 |
) |
| 903 | 1009 |
} |
| 904 | 1010 |
|
@@ -937,10 +1043,37 @@ enum ChargerStandbyPowerMeasurementAnalyzer {
|
||
| 937 | 1043 |
averageVoltageVolts: statistics.averageVoltageVolts, |
| 938 | 1044 |
stabilityDeltaWatts: statistics.stabilityDeltaWatts, |
| 939 | 1045 |
stabilityToleranceWatts: statistics.stabilityToleranceWatts, |
| 940 |
- powerSamplesWatts: samples.map(\.powerWatts) |
|
| 1046 |
+ storedHistogram: statistics.histogram |
|
| 941 | 1047 |
) |
| 942 | 1048 |
} |
| 943 | 1049 |
|
| 1050 |
+ /// Merges consecutive bins in groups of `factor`. Returns the input unchanged when factor ≤ 1. |
|
| 1051 |
+ static func downsample( |
|
| 1052 |
+ _ bins: [ChargerStandbyPowerDistributionBin], |
|
| 1053 |
+ factor: Int |
|
| 1054 |
+ ) -> [ChargerStandbyPowerDistributionBin] {
|
|
| 1055 |
+ guard factor > 1, !bins.isEmpty else { return bins }
|
|
| 1056 |
+ let totalCount = bins.reduce(0) { $0 + $1.count }
|
|
| 1057 |
+ var result: [ChargerStandbyPowerDistributionBin] = [] |
|
| 1058 |
+ var inputIndex = 0 |
|
| 1059 |
+ var outputIndex = 0 |
|
| 1060 |
+ while inputIndex < bins.count {
|
|
| 1061 |
+ let group = Array(bins[inputIndex..<min(inputIndex + factor, bins.count)]) |
|
| 1062 |
+ let mergedCount = group.reduce(0) { $0 + $1.count }
|
|
| 1063 |
+ let relFreq = totalCount > 0 ? Double(mergedCount) / Double(totalCount) : 0 |
|
| 1064 |
+ result.append(ChargerStandbyPowerDistributionBin( |
|
| 1065 |
+ index: outputIndex, |
|
| 1066 |
+ lowerBoundWatts: group.first!.lowerBoundWatts, |
|
| 1067 |
+ upperBoundWatts: group.last!.upperBoundWatts, |
|
| 1068 |
+ count: mergedCount, |
|
| 1069 |
+ relativeFrequency: relFreq |
|
| 1070 |
+ )) |
|
| 1071 |
+ inputIndex += factor |
|
| 1072 |
+ outputIndex += 1 |
|
| 1073 |
+ } |
|
| 1074 |
+ return result |
|
| 1075 |
+ } |
|
| 1076 |
+ |
|
| 944 | 1077 |
static func histogram(for values: [Double], preferredBinCount: Int? = nil) -> [ChargerStandbyPowerDistributionBin] {
|
| 945 | 1078 |
let finiteValues = values.filter(\.isFinite) |
| 946 | 1079 |
guard finiteValues.isEmpty == false else {
|
@@ -13,21 +13,27 @@ struct SidebarChargedDevicesSectionView: View {
|
||
| 13 | 13 |
let chargedDevices: [ChargedDeviceSummary] |
| 14 | 14 |
let emptyStateText: String |
| 15 | 15 |
let tint: Color |
| 16 |
+ let isExpanded: Bool |
|
| 17 |
+ let onToggle: () -> Void |
|
| 16 | 18 |
let onAdd: () -> Void |
| 17 | 19 |
|
| 18 | 20 |
var body: some View {
|
| 19 | 21 |
Section(header: headerView) {
|
| 20 |
- // Library overview row — navigates to the full management library |
|
| 21 |
- NavigationLink(destination: SidebarChargedDeviceLibraryView(mode: mode)) {
|
|
| 22 |
- libraryRow |
|
| 23 |
- } |
|
| 24 |
- .buttonStyle(.plain) |
|
| 25 |
- |
|
| 26 |
- ForEach(chargedDevices) { chargedDevice in
|
|
| 27 |
- NavigationLink(destination: ChargedDeviceDetailView(chargedDeviceID: chargedDevice.id)) {
|
|
| 28 |
- ChargedDeviceSidebarCardView(chargedDevice: chargedDevice) |
|
| 22 |
+ if isExpanded {
|
|
| 23 |
+ // Library overview row — navigates to the full management library |
|
| 24 |
+ NavigationLink(destination: SidebarChargedDeviceLibraryView(mode: mode)) {
|
|
| 25 |
+ libraryRow |
|
| 29 | 26 |
} |
| 30 | 27 |
.buttonStyle(.plain) |
| 28 |
+ .transition(.opacity.combined(with: .move(edge: .top))) |
|
| 29 |
+ |
|
| 30 |
+ ForEach(chargedDevices) { chargedDevice in
|
|
| 31 |
+ NavigationLink(destination: ChargedDeviceDetailView(chargedDeviceID: chargedDevice.id)) {
|
|
| 32 |
+ ChargedDeviceSidebarCardView(chargedDevice: chargedDevice) |
|
| 33 |
+ } |
|
| 34 |
+ .buttonStyle(.plain) |
|
| 35 |
+ .transition(.opacity.combined(with: .move(edge: .top))) |
|
| 36 |
+ } |
|
| 31 | 37 |
} |
| 32 | 38 |
} |
| 33 | 39 |
} |
@@ -58,8 +64,18 @@ struct SidebarChargedDevicesSectionView: View {
|
||
| 58 | 64 |
|
| 59 | 65 |
private var headerView: some View {
|
| 60 | 66 |
HStack(alignment: .firstTextBaseline, spacing: 10) {
|
| 61 |
- Text(title) |
|
| 62 |
- .font(.headline) |
|
| 67 |
+ Button(action: onToggle) {
|
|
| 68 |
+ HStack(alignment: .firstTextBaseline, spacing: 4) {
|
|
| 69 |
+ Image(systemName: "chevron.right") |
|
| 70 |
+ .font(.caption.weight(.semibold)) |
|
| 71 |
+ .foregroundColor(.secondary) |
|
| 72 |
+ .rotationEffect(.degrees(isExpanded ? 90 : 0)) |
|
| 73 |
+ .animation(.easeInOut(duration: 0.22), value: isExpanded) |
|
| 74 |
+ Text(title) |
|
| 75 |
+ .font(.headline) |
|
| 76 |
+ } |
|
| 77 |
+ } |
|
| 78 |
+ .buttonStyle(.plain) |
|
| 63 | 79 |
Spacer() |
| 64 | 80 |
Button(action: onAdd) {
|
| 65 | 81 |
Image(systemName: "plus.circle.fill") |
@@ -5,21 +5,24 @@ |
||
| 5 | 5 |
|
| 6 | 6 |
import SwiftUI |
| 7 | 7 |
|
| 8 |
-struct MeterInfoCardView<Content: View>: View {
|
|
| 8 |
+struct MeterInfoCardView<Content: View, TrailingActions: View>: View {
|
|
| 9 | 9 |
let title: String |
| 10 | 10 |
let infoMessage: String? |
| 11 | 11 |
let tint: Color |
| 12 |
+ @ViewBuilder var trailingActions: TrailingActions |
|
| 12 | 13 |
@ViewBuilder var content: Content |
| 13 | 14 |
|
| 14 | 15 |
init( |
| 15 | 16 |
title: String, |
| 16 | 17 |
infoMessage: String? = nil, |
| 17 | 18 |
tint: Color, |
| 19 |
+ @ViewBuilder trailingActions: () -> TrailingActions = { EmptyView() },
|
|
| 18 | 20 |
@ViewBuilder content: () -> Content |
| 19 | 21 |
) {
|
| 20 | 22 |
self.title = title |
| 21 | 23 |
self.infoMessage = infoMessage |
| 22 | 24 |
self.tint = tint |
| 25 |
+ self.trailingActions = trailingActions() |
|
| 23 | 26 |
self.content = content() |
| 24 | 27 |
} |
| 25 | 28 |
|
@@ -31,6 +34,8 @@ struct MeterInfoCardView<Content: View>: View {
|
||
| 31 | 34 |
if let infoMessage {
|
| 32 | 35 |
ContextInfoButton(title: title, message: infoMessage) |
| 33 | 36 |
} |
| 37 |
+ Spacer(minLength: 0) |
|
| 38 |
+ trailingActions |
|
| 34 | 39 |
} |
| 35 | 40 |
content |
| 36 | 41 |
} |
@@ -161,14 +161,11 @@ struct MeterChargeRecordContentView: View {
|
||
| 161 | 161 |
if let openChargeSession {
|
| 162 | 162 |
chargingMonitorCard(openChargeSession) |
| 163 | 163 |
|
| 164 |
- if showsMeterTotalsCard {
|
|
| 165 |
- meterTotalsCard |
|
| 166 |
- } |
|
| 167 |
- |
|
| 168 | 164 |
if let range = sessionChartTimeRange {
|
| 169 | 165 |
sessionChartCard(timeRange: range, session: openChargeSession) |
| 170 | 166 |
} |
| 171 | 167 |
} else {
|
| 168 |
+ liveMeterStripView |
|
| 172 | 169 |
modePicker |
| 173 | 170 |
|
| 174 | 171 |
switch activeMode {
|
@@ -177,10 +174,6 @@ struct MeterChargeRecordContentView: View {
|
||
| 177 | 174 |
case .standbyPower: |
| 178 | 175 |
standbyPowerCard |
| 179 | 176 |
} |
| 180 |
- |
|
| 181 |
- if showsMeterTotalsCard {
|
|
| 182 |
- meterTotalsCard |
|
| 183 |
- } |
|
| 184 | 177 |
} |
| 185 | 178 |
} |
| 186 | 179 |
.padding() |
@@ -593,15 +586,29 @@ struct MeterChargeRecordContentView: View {
|
||
| 593 | 586 |
.meterCard(tint: .orange, fillOpacity: 0.10, strokeOpacity: 0.16) |
| 594 | 587 |
} |
| 595 | 588 |
|
| 589 |
+ // MARK: - Live Meter Strip (idle state) |
|
| 590 |
+ |
|
| 591 |
+ private var liveMeterStripView: some View {
|
|
| 592 |
+ let columns = [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())] |
|
| 593 |
+ return LazyVGrid(columns: columns, spacing: 8) {
|
|
| 594 |
+ metricCell(label: "Power", value: "\(usbMeter.power.format(decimalDigits: 2)) W", tint: .yellow) |
|
| 595 |
+ metricCell(label: "Current", value: "\(usbMeter.current.format(decimalDigits: 3)) A", tint: .blue) |
|
| 596 |
+ metricCell(label: "Voltage", value: "\(usbMeter.voltage.format(decimalDigits: 2)) V", tint: .teal) |
|
| 597 |
+ } |
|
| 598 |
+ } |
|
| 599 |
+ |
|
| 596 | 600 |
// MARK: - Charging Monitor Card |
| 597 | 601 |
|
| 598 | 602 |
private func chargingMonitorCard(_ openChargeSession: ChargeSessionSummary) -> some View {
|
| 599 | 603 |
let displayedEnergyWh = displayedSessionEnergyWh(for: openChargeSession) |
| 600 | 604 |
let displayedChargeAh = displayedSessionChargeAh(for: openChargeSession) |
| 601 | 605 |
let canAddCheckpoint = appData.canAddBatteryCheckpoint(to: openChargeSession.id) |
| 602 |
- let metricRows = sessionMetricRows(for: openChargeSession, displayedEnergyWh: displayedEnergyWh) |
|
| 606 |
+ let batteryPrediction = selectedChargedDevice?.batteryLevelPrediction( |
|
| 607 |
+ for: openChargeSession, |
|
| 608 |
+ effectiveEnergyWhOverride: displayedEnergyWh |
|
| 609 |
+ ) |
|
| 603 | 610 |
|
| 604 |
- return VStack(alignment: .leading, spacing: 12) {
|
|
| 611 |
+ return VStack(alignment: .leading, spacing: 14) {
|
|
| 605 | 612 |
// Header |
| 606 | 613 |
HStack {
|
| 607 | 614 |
if let device = selectedChargedDevice {
|
@@ -619,9 +626,46 @@ struct MeterChargeRecordContentView: View {
|
||
| 619 | 626 |
.meterCard(tint: headerStatusColor, fillOpacity: 0.18, strokeOpacity: 0.24, cornerRadius: 999) |
| 620 | 627 |
} |
| 621 | 628 |
|
| 622 |
- ChargeRecordMetricsTableView( |
|
| 623 |
- labels: metricRows.map(\.label), |
|
| 624 |
- values: metricRows.map(\.value) |
|
| 629 |
+ // Orphaned session warning — device was deleted from library |
|
| 630 |
+ if selectedChargedDevice == nil {
|
|
| 631 |
+ VStack(alignment: .leading, spacing: 8) {
|
|
| 632 |
+ Label("Device removed from library", systemImage: "exclamationmark.triangle.fill")
|
|
| 633 |
+ .font(.subheadline.weight(.semibold)) |
|
| 634 |
+ .foregroundColor(.orange) |
|
| 635 |
+ Text("The device associated with this session no longer exists. Stop the session to close it.")
|
|
| 636 |
+ .font(.caption) |
|
| 637 |
+ .foregroundColor(.secondary) |
|
| 638 |
+ Button("Stop Session") {
|
|
| 639 |
+ finalCheckpointMode = .skip |
|
| 640 |
+ finalCheckpointText = "" |
|
| 641 |
+ _ = appData.stopChargeSession( |
|
| 642 |
+ sessionID: openChargeSession.id, |
|
| 643 |
+ finalBatteryPercent: nil |
|
| 644 |
+ ) |
|
| 645 |
+ } |
|
| 646 |
+ .frame(maxWidth: .infinity) |
|
| 647 |
+ .padding(.vertical, 9) |
|
| 648 |
+ .meterCard(tint: .red, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 12) |
|
| 649 |
+ .buttonStyle(.plain) |
|
| 650 |
+ } |
|
| 651 |
+ .padding(14) |
|
| 652 |
+ .meterCard(tint: .orange, fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 16) |
|
| 653 |
+ } |
|
| 654 |
+ |
|
| 655 |
+ // Battery prediction gauge |
|
| 656 |
+ if let batteryPrediction {
|
|
| 657 |
+ batteryGaugeSection( |
|
| 658 |
+ prediction: batteryPrediction, |
|
| 659 |
+ session: openChargeSession, |
|
| 660 |
+ displayedEnergyWh: displayedEnergyWh |
|
| 661 |
+ ) |
|
| 662 |
+ } |
|
| 663 |
+ |
|
| 664 |
+ // Metrics grid |
|
| 665 |
+ sessionMetricsGrid( |
|
| 666 |
+ for: openChargeSession, |
|
| 667 |
+ displayedEnergyWh: displayedEnergyWh, |
|
| 668 |
+ hasPrediction: batteryPrediction != nil |
|
| 625 | 669 |
) |
| 626 | 670 |
|
| 627 | 671 |
if openChargeSession.stopThresholdAmps > 0 {
|
@@ -630,21 +674,6 @@ struct MeterChargeRecordContentView: View {
|
||
| 630 | 674 |
.foregroundColor(.secondary) |
| 631 | 675 |
} |
| 632 | 676 |
|
| 633 |
- if let batteryPrediction = selectedChargedDevice?.batteryLevelPrediction( |
|
| 634 |
- for: openChargeSession, |
|
| 635 |
- effectiveEnergyWhOverride: displayedEnergyWh |
|
| 636 |
- ) {
|
|
| 637 |
- HStack(spacing: 6) {
|
|
| 638 |
- Image(systemName: "battery.75percent") |
|
| 639 |
- .foregroundColor(.green) |
|
| 640 |
- Text("Predicted: \(batteryPrediction.predictedPercent.format(decimalDigits: 0))%")
|
|
| 641 |
- .font(.caption.weight(.semibold)) |
|
| 642 |
- Text("· \(batteryPrediction.estimatedCapacityWh.format(decimalDigits: 2)) Wh est.")
|
|
| 643 |
- .font(.caption2) |
|
| 644 |
- .foregroundColor(.secondary) |
|
| 645 |
- } |
|
| 646 |
- } |
|
| 647 |
- |
|
| 648 | 677 |
if let sessionWarning = sessionWarning(for: openChargeSession) {
|
| 649 | 678 |
Label(sessionWarning, systemImage: "exclamationmark.triangle") |
| 650 | 679 |
.font(.caption) |
@@ -679,16 +708,12 @@ struct MeterChargeRecordContentView: View {
|
||
| 679 | 708 |
|
| 680 | 709 |
targetSectionView( |
| 681 | 710 |
for: openChargeSession, |
| 682 |
- predictedPercent: selectedChargedDevice?.batteryLevelPrediction( |
|
| 683 |
- for: openChargeSession, |
|
| 684 |
- effectiveEnergyWhOverride: displayedEnergyWh |
|
| 685 |
- )?.predictedPercent |
|
| 711 |
+ predictedPercent: batteryPrediction?.predictedPercent |
|
| 686 | 712 |
) |
| 687 | 713 |
|
| 688 | 714 |
if showingStopConfirm {
|
| 689 | 715 |
stopConfirmPanel(for: openChargeSession) |
| 690 | 716 |
} else {
|
| 691 |
- // Session controls |
|
| 692 | 717 |
HStack(spacing: 10) {
|
| 693 | 718 |
if openChargeSession.status == .active {
|
| 694 | 719 |
Button("Pause") {
|
@@ -724,6 +749,208 @@ struct MeterChargeRecordContentView: View {
|
||
| 724 | 749 |
.meterCard(tint: .green, fillOpacity: 0.14, strokeOpacity: 0.20) |
| 725 | 750 |
} |
| 726 | 751 |
|
| 752 |
+ // MARK: - Battery Gauge Section |
|
| 753 |
+ |
|
| 754 |
+ private func batteryGaugeSection( |
|
| 755 |
+ prediction: BatteryLevelPrediction, |
|
| 756 |
+ session: ChargeSessionSummary, |
|
| 757 |
+ displayedEnergyWh: Double |
|
| 758 |
+ ) -> some View {
|
|
| 759 |
+ let percent = prediction.predictedPercent |
|
| 760 |
+ let color = batteryColor(for: percent) |
|
| 761 |
+ let duration = max(session.effectiveDuration, 0) |
|
| 762 |
+ let rateWhPerSec: Double? = duration > 300 && displayedEnergyWh > 0.01 |
|
| 763 |
+ ? displayedEnergyWh / duration |
|
| 764 |
+ : nil |
|
| 765 |
+ |
|
| 766 |
+ let etaToFull: String? = {
|
|
| 767 |
+ guard let rate = rateWhPerSec, rate > 0.0001, percent < 98 else { return nil }
|
|
| 768 |
+ let remaining = max(prediction.estimatedCapacityWh - displayedEnergyWh, 0) |
|
| 769 |
+ let seconds = remaining / rate |
|
| 770 |
+ return seconds > 120 ? formatETA(seconds) : nil |
|
| 771 |
+ }() |
|
| 772 |
+ |
|
| 773 |
+ let etaToTarget: String? = {
|
|
| 774 |
+ guard let target = session.targetBatteryPercent, target > percent + 1, |
|
| 775 |
+ let rate = rateWhPerSec, rate > 0.0001 else { return nil }
|
|
| 776 |
+ let targetEnergyWh = (target / 100) * prediction.estimatedCapacityWh |
|
| 777 |
+ let remaining = max(targetEnergyWh - displayedEnergyWh, 0) |
|
| 778 |
+ let seconds = remaining / rate |
|
| 779 |
+ return seconds > 120 ? formatETA(seconds) : nil |
|
| 780 |
+ }() |
|
| 781 |
+ |
|
| 782 |
+ return VStack(spacing: 10) {
|
|
| 783 |
+ HStack(alignment: .lastTextBaseline, spacing: 8) {
|
|
| 784 |
+ HStack(alignment: .lastTextBaseline, spacing: 3) {
|
|
| 785 |
+ Text("\(Int(percent.rounded()))")
|
|
| 786 |
+ .font(.system(size: 52, weight: .bold, design: .rounded)) |
|
| 787 |
+ .foregroundColor(color) |
|
| 788 |
+ .monospacedDigit() |
|
| 789 |
+ Text("%")
|
|
| 790 |
+ .font(.title2.weight(.semibold)) |
|
| 791 |
+ .foregroundColor(color.opacity(0.8)) |
|
| 792 |
+ } |
|
| 793 |
+ Spacer() |
|
| 794 |
+ VStack(alignment: .trailing, spacing: 2) {
|
|
| 795 |
+ Text("\(prediction.estimatedCapacityWh.format(decimalDigits: 2)) Wh")
|
|
| 796 |
+ .font(.callout.weight(.bold)) |
|
| 797 |
+ .foregroundColor(.orange) |
|
| 798 |
+ .monospacedDigit() |
|
| 799 |
+ Text("est. capacity")
|
|
| 800 |
+ .font(.caption2) |
|
| 801 |
+ .foregroundColor(.secondary) |
|
| 802 |
+ } |
|
| 803 |
+ } |
|
| 804 |
+ |
|
| 805 |
+ batteryProgressBar( |
|
| 806 |
+ percent: percent, |
|
| 807 |
+ startPercent: session.startBatteryPercent, |
|
| 808 |
+ targetPercent: session.targetBatteryPercent |
|
| 809 |
+ ) |
|
| 810 |
+ |
|
| 811 |
+ HStack(spacing: 14) {
|
|
| 812 |
+ if let etaToFull {
|
|
| 813 |
+ VStack(alignment: .leading, spacing: 1) {
|
|
| 814 |
+ HStack(spacing: 4) {
|
|
| 815 |
+ Image(systemName: "clock.fill") |
|
| 816 |
+ .font(.caption) |
|
| 817 |
+ .foregroundColor(.green) |
|
| 818 |
+ Text(etaToFull) |
|
| 819 |
+ .font(.caption.weight(.bold)) |
|
| 820 |
+ } |
|
| 821 |
+ Text("to full")
|
|
| 822 |
+ .font(.caption2) |
|
| 823 |
+ .foregroundColor(.secondary) |
|
| 824 |
+ } |
|
| 825 |
+ } |
|
| 826 |
+ if let etaToTarget, let target = session.targetBatteryPercent {
|
|
| 827 |
+ VStack(alignment: .leading, spacing: 1) {
|
|
| 828 |
+ HStack(spacing: 4) {
|
|
| 829 |
+ Image(systemName: "bell.badge.fill") |
|
| 830 |
+ .font(.caption) |
|
| 831 |
+ .foregroundColor(.indigo) |
|
| 832 |
+ Text(etaToTarget) |
|
| 833 |
+ .font(.caption.weight(.bold)) |
|
| 834 |
+ } |
|
| 835 |
+ Text("to \(Int(target.rounded()))%")
|
|
| 836 |
+ .font(.caption2) |
|
| 837 |
+ .foregroundColor(.secondary) |
|
| 838 |
+ } |
|
| 839 |
+ } |
|
| 840 |
+ Spacer() |
|
| 841 |
+ Text("anchored to \(prediction.anchorDescription) at \(prediction.anchorPercent.format(decimalDigits: 0))%")
|
|
| 842 |
+ .font(.caption2) |
|
| 843 |
+ .foregroundColor(.secondary) |
|
| 844 |
+ .multilineTextAlignment(.trailing) |
|
| 845 |
+ } |
|
| 846 |
+ } |
|
| 847 |
+ .padding(14) |
|
| 848 |
+ .meterCard(tint: color, fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 16) |
|
| 849 |
+ } |
|
| 850 |
+ |
|
| 851 |
+ private func batteryProgressBar( |
|
| 852 |
+ percent: Double, |
|
| 853 |
+ startPercent: Double?, |
|
| 854 |
+ targetPercent: Double? |
|
| 855 |
+ ) -> some View {
|
|
| 856 |
+ let color = batteryColor(for: percent) |
|
| 857 |
+ return GeometryReader { geo in
|
|
| 858 |
+ let width = geo.size.width |
|
| 859 |
+ ZStack(alignment: .leading) {
|
|
| 860 |
+ Capsule() |
|
| 861 |
+ .fill(Color.primary.opacity(0.10)) |
|
| 862 |
+ Rectangle() |
|
| 863 |
+ .fill( |
|
| 864 |
+ LinearGradient( |
|
| 865 |
+ colors: [color.opacity(0.6), color], |
|
| 866 |
+ startPoint: .leading, |
|
| 867 |
+ endPoint: .trailing |
|
| 868 |
+ ) |
|
| 869 |
+ ) |
|
| 870 |
+ .frame(width: max(width * CGFloat(percent / 100), 4)) |
|
| 871 |
+ .animation(.easeInOut(duration: 0.4), value: percent) |
|
| 872 |
+ if let start = startPercent, start > 2, start < 98 {
|
|
| 873 |
+ Rectangle() |
|
| 874 |
+ .fill(Color.white.opacity(0.55)) |
|
| 875 |
+ .frame(width: 2, height: 20) |
|
| 876 |
+ .offset(x: width * CGFloat(start / 100) - 1) |
|
| 877 |
+ } |
|
| 878 |
+ if let target = targetPercent {
|
|
| 879 |
+ Rectangle() |
|
| 880 |
+ .fill(Color.indigo.opacity(0.9)) |
|
| 881 |
+ .frame(width: 2.5, height: 20) |
|
| 882 |
+ .offset(x: width * CGFloat(target / 100) - 1.25) |
|
| 883 |
+ } |
|
| 884 |
+ } |
|
| 885 |
+ .clipShape(Capsule()) |
|
| 886 |
+ } |
|
| 887 |
+ .frame(height: 20) |
|
| 888 |
+ } |
|
| 889 |
+ |
|
| 890 |
+ private func batteryColor(for percent: Double) -> Color {
|
|
| 891 |
+ if percent >= 75 { return .green }
|
|
| 892 |
+ if percent >= 35 { return .orange }
|
|
| 893 |
+ return .red |
|
| 894 |
+ } |
|
| 895 |
+ |
|
| 896 |
+ private func formatETA(_ seconds: TimeInterval) -> String {
|
|
| 897 |
+ let totalMinutes = Int(seconds / 60) |
|
| 898 |
+ if totalMinutes < 60 { return "\(totalMinutes)m" }
|
|
| 899 |
+ let hours = totalMinutes / 60 |
|
| 900 |
+ let minutes = totalMinutes % 60 |
|
| 901 |
+ return minutes == 0 ? "\(hours)h" : "\(hours)h \(minutes)m" |
|
| 902 |
+ } |
|
| 903 |
+ |
|
| 904 |
+ // MARK: - Session Metrics Grid |
|
| 905 |
+ |
|
| 906 |
+ private func sessionMetricsGrid( |
|
| 907 |
+ for session: ChargeSessionSummary, |
|
| 908 |
+ displayedEnergyWh: Double, |
|
| 909 |
+ hasPrediction: Bool |
|
| 910 |
+ ) -> some View {
|
|
| 911 |
+ let capacityFallback: Double? = hasPrediction ? nil : ( |
|
| 912 |
+ session.capacityEstimateWh |
|
| 913 |
+ ?? selectedChargedDevice?.estimatedBatteryCapacityWh(for: session.chargingTransportMode) |
|
| 914 |
+ ?? selectedChargedDevice?.estimatedBatteryCapacityWh |
|
| 915 |
+ ) |
|
| 916 |
+ let columns = [GridItem(.flexible()), GridItem(.flexible())] |
|
| 917 |
+ |
|
| 918 |
+ return LazyVGrid(columns: columns, spacing: 8) {
|
|
| 919 |
+ metricCell(label: "Energy", value: "\(displayedEnergyWh.format(decimalDigits: 3)) Wh", tint: .blue) |
|
| 920 |
+ metricCell(label: "Duration", value: formatDuration(max(session.effectiveDuration, 0)), tint: .teal) |
|
| 921 |
+ |
|
| 922 |
+ if shouldShowChargingTransport(for: session) {
|
|
| 923 |
+ metricCell(label: "Type", value: session.chargingTransportMode.title, tint: .orange) |
|
| 924 |
+ } |
|
| 925 |
+ if shouldShowChargingState(for: session) {
|
|
| 926 |
+ metricCell(label: "Mode", value: session.chargingStateMode.title, tint: .purple) |
|
| 927 |
+ } |
|
| 928 |
+ |
|
| 929 |
+ metricCell(label: "Auto Stop", value: autoStopLabel(for: session), tint: .secondary) |
|
| 930 |
+ |
|
| 931 |
+ if let capacity = capacityFallback {
|
|
| 932 |
+ metricCell(label: "Est. Capacity", value: "\(capacity.format(decimalDigits: 2)) Wh", tint: .orange) |
|
| 933 |
+ } |
|
| 934 |
+ } |
|
| 935 |
+ } |
|
| 936 |
+ |
|
| 937 |
+ private func metricCell(label: String, value: String, tint: Color) -> some View {
|
|
| 938 |
+ VStack(alignment: .leading, spacing: 3) {
|
|
| 939 |
+ Text(label) |
|
| 940 |
+ .font(.caption2) |
|
| 941 |
+ .foregroundColor(.secondary) |
|
| 942 |
+ Text(value) |
|
| 943 |
+ .font(.subheadline.weight(.semibold)) |
|
| 944 |
+ .lineLimit(1) |
|
| 945 |
+ .minimumScaleFactor(0.7) |
|
| 946 |
+ .monospacedDigit() |
|
| 947 |
+ } |
|
| 948 |
+ .frame(maxWidth: .infinity, alignment: .leading) |
|
| 949 |
+ .padding(.horizontal, 12) |
|
| 950 |
+ .padding(.vertical, 10) |
|
| 951 |
+ .meterCard(tint: tint, fillOpacity: 0.08, strokeOpacity: 0.12, cornerRadius: 12) |
|
| 952 |
+ } |
|
| 953 |
+ |
|
| 727 | 954 |
private func completionConfirmationCard(_ openChargeSession: ChargeSessionSummary) -> some View {
|
| 728 | 955 |
VStack(alignment: .leading, spacing: 10) {
|
| 729 | 956 |
Label("Charging may have stopped", systemImage: "questionmark.circle.fill")
|
@@ -6,6 +6,7 @@ |
||
| 6 | 6 |
// |
| 7 | 7 |
|
| 8 | 8 |
import SwiftUI |
| 9 |
+import UniformTypeIdentifiers |
|
| 9 | 10 |
|
| 10 | 11 |
struct ChargerStandbyPowerWizardView: View {
|
| 11 | 12 |
@EnvironmentObject private var appData: AppData |
@@ -337,7 +338,7 @@ struct ChargerStandbyPowerWizardView: View {
|
||
| 337 | 338 |
projectedYearlyEnergyWh: statistics.projectedYearlyEnergyWh |
| 338 | 339 |
) |
| 339 | 340 |
|
| 340 |
- distributionCard( |
|
| 341 |
+ StandbyPowerDistributionCard( |
|
| 341 | 342 |
histogram: statistics.histogram, |
| 342 | 343 |
averagePowerWatts: statistics.averagePowerWatts, |
| 343 | 344 |
standardDeviationPowerWatts: statistics.standardDeviationPowerWatts, |
@@ -432,41 +433,6 @@ struct ChargerStandbyPowerWizardView: View {
|
||
| 432 | 433 |
} |
| 433 | 434 |
} |
| 434 | 435 |
|
| 435 |
- private func distributionCard( |
|
| 436 |
- histogram: [ChargerStandbyPowerDistributionBin], |
|
| 437 |
- averagePowerWatts: Double, |
|
| 438 |
- standardDeviationPowerWatts: Double, |
|
| 439 |
- tint: Color |
|
| 440 |
- ) -> some View {
|
|
| 441 |
- MeterInfoCardView( |
|
| 442 |
- title: "Distribution", |
|
| 443 |
- infoMessage: "Bars show how often each power interval appeared. The curve overlays a normal approximation around the observed mean and deviation.", |
|
| 444 |
- tint: tint |
|
| 445 |
- ) {
|
|
| 446 |
- StandbyPowerHistogramView( |
|
| 447 |
- histogram: histogram, |
|
| 448 |
- averagePowerWatts: averagePowerWatts, |
|
| 449 |
- standardDeviationPowerWatts: standardDeviationPowerWatts, |
|
| 450 |
- tint: tint |
|
| 451 |
- ) |
|
| 452 |
- .frame(height: 220) |
|
| 453 |
- |
|
| 454 |
- if let firstBin = histogram.first, let lastBin = histogram.last {
|
|
| 455 |
- let midpointWatts = (firstBin.lowerBoundWatts + lastBin.upperBoundWatts) / 2 |
|
| 456 |
- HStack {
|
|
| 457 |
- Text("\(firstBin.lowerBoundWatts.format(decimalDigits: 3)) W")
|
|
| 458 |
- Spacer() |
|
| 459 |
- Text("\(midpointWatts.format(decimalDigits: 3)) W")
|
|
| 460 |
- Spacer() |
|
| 461 |
- Text("\(lastBin.upperBoundWatts.format(decimalDigits: 3)) W")
|
|
| 462 |
- } |
|
| 463 |
- .font(.caption) |
|
| 464 |
- .foregroundColor(.secondary) |
|
| 465 |
- .monospacedDigit() |
|
| 466 |
- } |
|
| 467 |
- } |
|
| 468 |
- } |
|
| 469 |
- |
|
| 470 | 436 |
private func statisticsCard( |
| 471 | 437 |
averagePowerWatts: Double, |
| 472 | 438 |
medianPowerWatts: Double, |
@@ -514,6 +480,124 @@ struct ChargerStandbyPowerWizardView: View {
|
||
| 514 | 480 |
|
| 515 | 481 |
} |
| 516 | 482 |
|
| 483 |
+// MARK: - Distribution card with resolution picker and CSV export |
|
| 484 |
+ |
|
| 485 |
+private struct StandbyPowerDistributionCard: View {
|
|
| 486 |
+ let histogram: [ChargerStandbyPowerDistributionBin] |
|
| 487 |
+ let averagePowerWatts: Double |
|
| 488 |
+ let standardDeviationPowerWatts: Double |
|
| 489 |
+ let tint: Color |
|
| 490 |
+ var showExport: Bool = false |
|
| 491 |
+ |
|
| 492 |
+ private func resolution(for width: CGFloat) -> HistogramResolution {
|
|
| 493 |
+ if width >= 600 { return .x4 }
|
|
| 494 |
+ if width >= 360 { return .x2 }
|
|
| 495 |
+ return .x1 |
|
| 496 |
+ } |
|
| 497 |
+ |
|
| 498 |
+ private func displayedHistogram(width: CGFloat) -> [ChargerStandbyPowerDistributionBin] {
|
|
| 499 |
+ let factor = HistogramResolution.x4.rawValue / resolution(for: width).rawValue |
|
| 500 |
+ return ChargerStandbyPowerMeasurementAnalyzer.downsample(histogram, factor: factor) |
|
| 501 |
+ } |
|
| 502 |
+ |
|
| 503 |
+ private var csvString: String {
|
|
| 504 |
+ var lines = ["Bin,Lower Bound (W),Upper Bound (W),Count,Relative Frequency (%)"] |
|
| 505 |
+ for bin in histogram {
|
|
| 506 |
+ lines.append( |
|
| 507 |
+ "\(bin.index + 1)," |
|
| 508 |
+ + String(format: "%.6f", bin.lowerBoundWatts) + "," |
|
| 509 |
+ + String(format: "%.6f", bin.upperBoundWatts) + "," |
|
| 510 |
+ + "\(bin.count)," |
|
| 511 |
+ + String(format: "%.4f", bin.relativeFrequency * 100) |
|
| 512 |
+ ) |
|
| 513 |
+ } |
|
| 514 |
+ return lines.joined(separator: "\n") |
|
| 515 |
+ } |
|
| 516 |
+ |
|
| 517 |
+ var body: some View {
|
|
| 518 |
+ MeterInfoCardView( |
|
| 519 |
+ title: "Value Spread", |
|
| 520 |
+ infoMessage: "Bars show how often each power interval appeared. The curve overlays a normal approximation around the observed mean and standard deviation.", |
|
| 521 |
+ tint: tint, |
|
| 522 |
+ trailingActions: {
|
|
| 523 |
+ if showExport {
|
|
| 524 |
+ if #available(iOS 16, *) {
|
|
| 525 |
+ ShareLink( |
|
| 526 |
+ item: DistributionCSVExport(content: csvString), |
|
| 527 |
+ preview: SharePreview("distribution.csv")
|
|
| 528 |
+ ) {
|
|
| 529 |
+ Image(systemName: "square.and.arrow.up") |
|
| 530 |
+ .font(.subheadline.weight(.medium)) |
|
| 531 |
+ .foregroundStyle(.secondary) |
|
| 532 |
+ } |
|
| 533 |
+ } else {
|
|
| 534 |
+ Button {
|
|
| 535 |
+ exportCSVLegacy(csvString) |
|
| 536 |
+ } label: {
|
|
| 537 |
+ Image(systemName: "square.and.arrow.up") |
|
| 538 |
+ .font(.subheadline.weight(.medium)) |
|
| 539 |
+ .foregroundStyle(.secondary) |
|
| 540 |
+ } |
|
| 541 |
+ } |
|
| 542 |
+ } |
|
| 543 |
+ } |
|
| 544 |
+ ) {
|
|
| 545 |
+ GeometryReader { proxy in
|
|
| 546 |
+ let bins = displayedHistogram(width: proxy.size.width) |
|
| 547 |
+ StandbyPowerHistogramView( |
|
| 548 |
+ histogram: bins, |
|
| 549 |
+ averagePowerWatts: averagePowerWatts, |
|
| 550 |
+ standardDeviationPowerWatts: standardDeviationPowerWatts, |
|
| 551 |
+ tint: tint |
|
| 552 |
+ ) |
|
| 553 |
+ |
|
| 554 |
+ if let firstBin = bins.first, let lastBin = bins.last {
|
|
| 555 |
+ let midpointWatts = (firstBin.lowerBoundWatts + lastBin.upperBoundWatts) / 2 |
|
| 556 |
+ VStack {
|
|
| 557 |
+ Spacer() |
|
| 558 |
+ HStack {
|
|
| 559 |
+ Text("\(firstBin.lowerBoundWatts.format(decimalDigits: 3)) W")
|
|
| 560 |
+ Spacer() |
|
| 561 |
+ Text("\(midpointWatts.format(decimalDigits: 3)) W")
|
|
| 562 |
+ Spacer() |
|
| 563 |
+ Text("\(lastBin.upperBoundWatts.format(decimalDigits: 3)) W")
|
|
| 564 |
+ } |
|
| 565 |
+ .font(.caption) |
|
| 566 |
+ .foregroundColor(.secondary) |
|
| 567 |
+ .monospacedDigit() |
|
| 568 |
+ } |
|
| 569 |
+ } |
|
| 570 |
+ } |
|
| 571 |
+ .frame(height: 240) |
|
| 572 |
+ } |
|
| 573 |
+ } |
|
| 574 |
+ |
|
| 575 |
+ private func exportCSVLegacy(_ csv: String) {
|
|
| 576 |
+ guard let windowScene = UIApplication.shared.connectedScenes |
|
| 577 |
+ .compactMap({ $0 as? UIWindowScene }).first,
|
|
| 578 |
+ let rootVC = windowScene.windows.first?.rootViewController else { return }
|
|
| 579 |
+ let activityVC = UIActivityViewController( |
|
| 580 |
+ activityItems: [csv], |
|
| 581 |
+ applicationActivities: nil |
|
| 582 |
+ ) |
|
| 583 |
+ rootVC.present(activityVC, animated: true) |
|
| 584 |
+ } |
|
| 585 |
+} |
|
| 586 |
+ |
|
| 587 |
+@available(iOS 16, *) |
|
| 588 |
+struct DistributionCSVExport: Transferable {
|
|
| 589 |
+ let content: String |
|
| 590 |
+ |
|
| 591 |
+ static var transferRepresentation: some TransferRepresentation {
|
|
| 592 |
+ DataRepresentation(exportedContentType: .commaSeparatedText) { export in
|
|
| 593 |
+ Data(export.content.utf8) |
|
| 594 |
+ } |
|
| 595 |
+ .suggestedFileName("distribution")
|
|
| 596 |
+ } |
|
| 597 |
+} |
|
| 598 |
+ |
|
| 599 |
+// MARK: - Histogram bars + Gaussian curve |
|
| 600 |
+ |
|
| 517 | 601 |
private struct StandbyPowerHistogramView: View {
|
| 518 | 602 |
let histogram: [ChargerStandbyPowerDistributionBin] |
| 519 | 603 |
let averagePowerWatts: Double |
@@ -758,6 +842,7 @@ struct ChargerStandbyPowerMeasurementDetailView: View {
|
||
| 758 | 842 |
.ignoresSafeArea() |
| 759 | 843 |
) |
| 760 | 844 |
.navigationTitle("Measurement")
|
| 845 |
+ .navigationBarTitleDisplayMode(.inline) |
|
| 761 | 846 |
.toolbar {
|
| 762 | 847 |
ToolbarItem(placement: .primaryAction) {
|
| 763 | 848 |
Button(role: .destructive) {
|
@@ -789,6 +874,7 @@ struct ChargerStandbyPowerMeasurementDetailView: View {
|
||
| 789 | 874 |
Text("This measurement is no longer available.")
|
| 790 | 875 |
.foregroundColor(.secondary) |
| 791 | 876 |
.navigationTitle("Measurement")
|
| 877 |
+ .navigationBarTitleDisplayMode(.inline) |
|
| 792 | 878 |
} |
| 793 | 879 |
} |
| 794 | 880 |
} |
@@ -874,33 +960,13 @@ private struct ChargerStandbyPowerMeasurementSnapshotView: View {
|
||
| 874 | 960 |
} |
| 875 | 961 |
|
| 876 | 962 |
private var distributionCard: some View {
|
| 877 |
- MeterInfoCardView( |
|
| 878 |
- title: "Distribution", |
|
| 879 |
- infoMessage: "Bars show how often each power interval appeared. The curve overlays a normal approximation around the observed mean and deviation.", |
|
| 880 |
- tint: .orange |
|
| 881 |
- ) {
|
|
| 882 |
- StandbyPowerHistogramView( |
|
| 883 |
- histogram: measurement.histogram, |
|
| 884 |
- averagePowerWatts: measurement.averagePowerWatts, |
|
| 885 |
- standardDeviationPowerWatts: measurement.standardDeviationPowerWatts, |
|
| 886 |
- tint: .orange |
|
| 887 |
- ) |
|
| 888 |
- .frame(height: 220) |
|
| 889 |
- |
|
| 890 |
- if let firstBin = measurement.histogram.first, let lastBin = measurement.histogram.last {
|
|
| 891 |
- let midpointWatts = (firstBin.lowerBoundWatts + lastBin.upperBoundWatts) / 2 |
|
| 892 |
- HStack {
|
|
| 893 |
- Text("\(firstBin.lowerBoundWatts.format(decimalDigits: 3)) W")
|
|
| 894 |
- Spacer() |
|
| 895 |
- Text("\(midpointWatts.format(decimalDigits: 3)) W")
|
|
| 896 |
- Spacer() |
|
| 897 |
- Text("\(lastBin.upperBoundWatts.format(decimalDigits: 3)) W")
|
|
| 898 |
- } |
|
| 899 |
- .font(.caption) |
|
| 900 |
- .foregroundColor(.secondary) |
|
| 901 |
- .monospacedDigit() |
|
| 902 |
- } |
|
| 903 |
- } |
|
| 963 |
+ StandbyPowerDistributionCard( |
|
| 964 |
+ histogram: measurement.storedHistogram, |
|
| 965 |
+ averagePowerWatts: measurement.averagePowerWatts, |
|
| 966 |
+ standardDeviationPowerWatts: measurement.standardDeviationPowerWatts, |
|
| 967 |
+ tint: .orange, |
|
| 968 |
+ showExport: true |
|
| 969 |
+ ) |
|
| 904 | 970 |
} |
| 905 | 971 |
|
| 906 | 972 |
private var statisticsCard: some View {
|
@@ -13,34 +13,41 @@ struct SidebarUSBMetersSectionView: View {
|
||
| 13 | 13 |
let scanStartedAt: Date? |
| 14 | 14 |
let now: Date |
| 15 | 15 |
let noDevicesHelpDelay: TimeInterval |
| 16 |
+ let isExpanded: Bool |
|
| 17 |
+ let onToggle: () -> Void |
|
| 16 | 18 |
let onAddMeter: () -> Void |
| 17 | 19 |
|
| 18 | 20 |
var body: some View {
|
| 19 | 21 |
Section(header: usbSectionHeader) {
|
| 20 |
- if meters.isEmpty {
|
|
| 21 |
- Text(devicesEmptyStateText) |
|
| 22 |
- .font(.footnote) |
|
| 23 |
- .foregroundColor(.secondary) |
|
| 24 |
- .frame(maxWidth: .infinity, alignment: .leading) |
|
| 25 |
- .padding(18) |
|
| 26 |
- .meterCard( |
|
| 27 |
- tint: isWaitingForFirstDiscovery ? .blue : .secondary, |
|
| 28 |
- fillOpacity: 0.14, |
|
| 29 |
- strokeOpacity: 0.20 |
|
| 30 |
- ) |
|
| 31 |
- } else {
|
|
| 32 |
- ForEach(meters) { meterSummary in
|
|
| 33 |
- if let meter = meterSummary.meter {
|
|
| 34 |
- NavigationLink(destination: MeterView().environmentObject(meter)) {
|
|
| 35 |
- MeterRowView() |
|
| 36 |
- .environmentObject(meter) |
|
| 37 |
- } |
|
| 38 |
- .buttonStyle(.plain) |
|
| 39 |
- } else {
|
|
| 40 |
- NavigationLink(destination: MeterDetailView(meterSummary: meterSummary)) {
|
|
| 41 |
- MeterCardView(meterSummary: meterSummary) |
|
| 22 |
+ if isExpanded {
|
|
| 23 |
+ if meters.isEmpty {
|
|
| 24 |
+ Text(devicesEmptyStateText) |
|
| 25 |
+ .font(.footnote) |
|
| 26 |
+ .foregroundColor(.secondary) |
|
| 27 |
+ .frame(maxWidth: .infinity, alignment: .leading) |
|
| 28 |
+ .padding(18) |
|
| 29 |
+ .meterCard( |
|
| 30 |
+ tint: isWaitingForFirstDiscovery ? .blue : .secondary, |
|
| 31 |
+ fillOpacity: 0.14, |
|
| 32 |
+ strokeOpacity: 0.20 |
|
| 33 |
+ ) |
|
| 34 |
+ .transition(.opacity.combined(with: .move(edge: .top))) |
|
| 35 |
+ } else {
|
|
| 36 |
+ ForEach(meters) { meterSummary in
|
|
| 37 |
+ if let meter = meterSummary.meter {
|
|
| 38 |
+ NavigationLink(destination: MeterView().environmentObject(meter)) {
|
|
| 39 |
+ MeterRowView() |
|
| 40 |
+ .environmentObject(meter) |
|
| 41 |
+ } |
|
| 42 |
+ .buttonStyle(.plain) |
|
| 43 |
+ .transition(.opacity.combined(with: .move(edge: .top))) |
|
| 44 |
+ } else {
|
|
| 45 |
+ NavigationLink(destination: MeterDetailView(meterSummary: meterSummary)) {
|
|
| 46 |
+ MeterCardView(meterSummary: meterSummary) |
|
| 47 |
+ } |
|
| 48 |
+ .buttonStyle(.plain) |
|
| 49 |
+ .transition(.opacity.combined(with: .move(edge: .top))) |
|
| 42 | 50 |
} |
| 43 |
- .buttonStyle(.plain) |
|
| 44 | 51 |
} |
| 45 | 52 |
} |
| 46 | 53 |
} |
@@ -69,16 +76,26 @@ struct SidebarUSBMetersSectionView: View {
|
||
| 69 | 76 |
|
| 70 | 77 |
private var usbSectionHeader: some View {
|
| 71 | 78 |
HStack(alignment: .firstTextBaseline) {
|
| 72 |
- VStack(alignment: .leading, spacing: 2) {
|
|
| 73 |
- Text("USB & Known Meters")
|
|
| 74 |
- .font(.headline) |
|
| 75 |
- if meters.isEmpty == false {
|
|
| 76 |
- Text(sectionSubtitleText) |
|
| 77 |
- .font(.caption) |
|
| 79 |
+ Button(action: onToggle) {
|
|
| 80 |
+ HStack(alignment: .firstTextBaseline, spacing: 4) {
|
|
| 81 |
+ Image(systemName: "chevron.right") |
|
| 82 |
+ .font(.caption.weight(.semibold)) |
|
| 78 | 83 |
.foregroundColor(.secondary) |
| 79 |
- .lineLimit(1) |
|
| 84 |
+ .rotationEffect(.degrees(isExpanded ? 90 : 0)) |
|
| 85 |
+ .animation(.easeInOut(duration: 0.22), value: isExpanded) |
|
| 86 |
+ VStack(alignment: .leading, spacing: 2) {
|
|
| 87 |
+ Text("USB & Known Meters")
|
|
| 88 |
+ .font(.headline) |
|
| 89 |
+ if meters.isEmpty == false {
|
|
| 90 |
+ Text(sectionSubtitleText) |
|
| 91 |
+ .font(.caption) |
|
| 92 |
+ .foregroundColor(.secondary) |
|
| 93 |
+ .lineLimit(1) |
|
| 94 |
+ } |
|
| 95 |
+ } |
|
| 80 | 96 |
} |
| 81 | 97 |
} |
| 98 |
+ .buttonStyle(.plain) |
|
| 82 | 99 |
Spacer() |
| 83 | 100 |
Button(action: onAddMeter) {
|
| 84 | 101 |
Image(systemName: "plus.circle.fill") |
@@ -25,6 +25,9 @@ private enum SidebarCreationSheet: Identifiable {
|
||
| 25 | 25 |
|
| 26 | 26 |
struct SidebarView: View {
|
| 27 | 27 |
@EnvironmentObject private var appData: AppData |
| 28 |
+ @State private var isUSBMetersExpanded = true |
|
| 29 |
+ @State private var isDevicesExpanded = true |
|
| 30 |
+ @State private var isChargersExpanded = true |
|
| 28 | 31 |
@State private var isHelpExpanded = false |
| 29 | 32 |
@State private var dismissedAutoHelpReason: SidebarHelpReason? |
| 30 | 33 |
@State private var now = Date() |
@@ -77,6 +80,12 @@ struct SidebarView: View {
|
||
| 77 | 80 |
scanStartedAt: appData.bluetoothManager.scanStartedAt, |
| 78 | 81 |
now: now, |
| 79 | 82 |
noDevicesHelpDelay: noDevicesHelpDelay, |
| 83 |
+ isExpanded: isUSBMetersExpanded, |
|
| 84 |
+ onToggle: {
|
|
| 85 |
+ withAnimation(.easeInOut(duration: 0.22)) {
|
|
| 86 |
+ isUSBMetersExpanded.toggle() |
|
| 87 |
+ } |
|
| 88 |
+ }, |
|
| 80 | 89 |
onAddMeter: { creationSheet = .meter }
|
| 81 | 90 |
) |
| 82 | 91 |
|
@@ -86,6 +95,12 @@ struct SidebarView: View {
|
||
| 86 | 95 |
chargedDevices: appData.deviceSummaries, |
| 87 | 96 |
emptyStateText: "No devices yet. Open Charge Record on a live meter or use the add button here to create one and start learning capacity.", |
| 88 | 97 |
tint: .orange, |
| 98 |
+ isExpanded: isDevicesExpanded, |
|
| 99 |
+ onToggle: {
|
|
| 100 |
+ withAnimation(.easeInOut(duration: 0.22)) {
|
|
| 101 |
+ isDevicesExpanded.toggle() |
|
| 102 |
+ } |
|
| 103 |
+ }, |
|
| 89 | 104 |
onAdd: { creationSheet = .device }
|
| 90 | 105 |
) |
| 91 | 106 |
|
@@ -95,6 +110,12 @@ struct SidebarView: View {
|
||
| 95 | 110 |
chargedDevices: appData.chargerSummaries, |
| 96 | 111 |
emptyStateText: "No chargers yet. Add one here so wireless sessions can track both the charged device and the charger being used.", |
| 97 | 112 |
tint: .pink, |
| 113 |
+ isExpanded: isChargersExpanded, |
|
| 114 |
+ onToggle: {
|
|
| 115 |
+ withAnimation(.easeInOut(duration: 0.22)) {
|
|
| 116 |
+ isChargersExpanded.toggle() |
|
| 117 |
+ } |
|
| 118 |
+ }, |
|
| 98 | 119 |
onAdd: { creationSheet = .charger }
|
| 99 | 120 |
) |
| 100 | 121 |
} |