Ampere-hours (Ah/mAh) are a charge metric that does not account for voltage variation, making them unsuitable for expressing battery capacity or session energy. The term was adopted abusively by consumer marketing and its use in this app was accidental. - Remove measuredChargeAh, meterChargeBaselineAh from all public summary structs (ChargeSessionSummary, ChargeCheckpointSummary, ChargeSessionSampleSummary); remove averageChargeAh from TypicalChargeCurvePoint - Simplify typical-curve interpolation to energy-only (chargeAh dimension was computed but never read by any view) - Remove displayedSessionChargeAh() helpers from AppData, ChargeSessionDetailView, ChargeSessionCompletionSheetView and all measuredChargeAhOverride plumbing through the checkpoint editor chain - Drop chargeRecordAH session-restore in Meter.syncMonitoringRestore (Wh path already covers this) - Remove "Measured Charge X.XXX Ah" row from the Energy card - CoreData schema attributes retained for backward compatibility; existing stored data is unaffected - Add collapsible support to MeterInfoCardView (isCollapsible + animated chevron); redesign session detail Overview card with compact 2-column stat grid; Observed Metrics defaults to collapsed - Add Documentation/No Ampere-Hours in UI or Model.md with rationale and enforcement rules Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@@ -0,0 +1,53 @@ |
||
| 1 |
+# No Ampere-Hours (Ah) in UI or Model |
|
| 2 |
+ |
|
| 3 |
+## Decision |
|
| 4 |
+ |
|
| 5 |
+The application does not display, expose, or compute user-visible measurements in ampere-hours (Ah or mAh). This is a deliberate policy, not an omission. |
|
| 6 |
+ |
|
| 7 |
+## Rationale |
|
| 8 |
+ |
|
| 9 |
+### The physics |
|
| 10 |
+ |
|
| 11 |
+Ampere-hours measure **electric charge** — the integral of current over time (∫ I·dt). They express how many coulombs of charge flowed through a circuit, independent of voltage. |
|
| 12 |
+ |
|
| 13 |
+Watt-hours measure **energy** — the integral of power over time (∫ V·I·dt). Energy is what actually matters for a battery: it tells you how much work the battery can do, accounting for the voltage at which charge is delivered. |
|
| 14 |
+ |
|
| 15 |
+For a battery with a flat discharge curve these two metrics are proportional, but real batteries have voltage that drops as they discharge. A 3.5 V lithium cell and a 5.0 V USB output delivering the "same" mAh are not delivering the same energy. The difference can be 30% or more. |
|
| 16 |
+ |
|
| 17 |
+### The marketing abuse |
|
| 18 |
+ |
|
| 19 |
+Consumer electronics marketing adopted mAh as a proxy for battery capacity because the number is conveniently large: |
|
| 20 |
+- "10,000 mAh power bank" sounds more impressive than "37 Wh power bank" |
|
| 21 |
+- But 10,000 mAh at 3.7 V (lithium cell voltage) ≈ 37 Wh, while 10,000 mAh at 5 V (USB output voltage) ≈ 50 Wh |
|
| 22 |
+- Manufacturers measure at cell voltage; the useful output is at USB voltage — the same number means different things depending on context |
|
| 23 |
+ |
|
| 24 |
+This ambiguity has been widely documented and is a known source of consumer confusion. Rating bodies and careful reviewers use Wh. |
|
| 25 |
+ |
|
| 26 |
+### For this app specifically |
|
| 27 |
+ |
|
| 28 |
+USB Meter measures charge delivered over USB, where voltage is never constant. Reporting in Ah would require specifying *which voltage* to use, and any fixed reference (3.7 V, 5 V, nominal) would be arbitrary and misleading. Wh is unambiguous: it is always the integral of V·I·dt as measured at the USB port. |
|
| 29 |
+ |
|
| 30 |
+## What this means in practice |
|
| 31 |
+ |
|
| 32 |
+### UI |
|
| 33 |
+- No measurement expressed in Ah or mAh should appear in any view |
|
| 34 |
+- Battery capacity is expressed exclusively in Wh (`capacityEstimateWh`) |
|
| 35 |
+- Session energy is expressed exclusively in Wh (`measuredEnergyWh`, `effectiveBatteryEnergyWh`) |
|
| 36 |
+ |
|
| 37 |
+### Model layer (Swift) |
|
| 38 |
+- `ChargeSessionSummary`, `ChargeCheckpointSummary`, and `ChargeSessionSampleSummary` do not expose Ah fields |
|
| 39 |
+- `TypicalChargeCurvePoint` carries only `averageEnergyWh` |
|
| 40 |
+- `ChargedDeviceSummary` capacity estimates are always in Wh |
|
| 41 |
+ |
|
| 42 |
+### Internal / hardware layer |
|
| 43 |
+- The USB meters (UM25C, UM34C, TC66C) report both a Wh counter (`recordedWH`) and an Ah counter (`recordedAH`) over Bluetooth |
|
| 44 |
+- `Meter.recordedAH` and `Meter.chargeRecordAH` exist as raw hardware counters and are used only internally — they are never surfaced in summaries or shown to users |
|
| 45 |
+- The CoreData schema retains `measuredChargeAh`, `meterChargeBaselineAh`, and `meterLastChargeAh` as legacy attributes (data already stored in existing records is preserved), but these attributes are no longer read into Swift model summaries |
|
| 46 |
+ |
|
| 47 |
+## Enforcement |
|
| 48 |
+ |
|
| 49 |
+When adding new features involving energy or capacity: |
|
| 50 |
+- Always use Wh as the unit |
|
| 51 |
+- Do not introduce new Ah-denominated properties in any public summary struct |
|
| 52 |
+- Do not display Ah or mAh strings in any view, label, or tooltip |
|
| 53 |
+- If a hardware counter comes in Ah (e.g., from a new meter protocol), store it as a private implementation detail and convert to energy only if an average voltage is reliably known |
|
@@ -607,13 +607,11 @@ final class AppData : ObservableObject {
|
||
| 607 | 607 |
|
| 608 | 608 |
let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description) |
| 609 | 609 |
let checkpointEnergyWh = activeSession.map { displayedSessionEnergyWh(for: $0, on: meter) }
|
| 610 |
- let checkpointChargeAh = activeSession.map { displayedSessionChargeAh(for: $0, on: meter) }
|
|
| 611 | 610 |
|
| 612 | 611 |
let didSave = chargeInsightsStore?.addBatteryCheckpoint( |
| 613 | 612 |
percent: percent, |
| 614 | 613 |
for: meter.btSerial.macAddress.description, |
| 615 |
- measuredEnergyWh: checkpointEnergyWh, |
|
| 616 |
- measuredChargeAh: checkpointChargeAh |
|
| 614 |
+ measuredEnergyWh: checkpointEnergyWh |
|
| 617 | 615 |
) ?? false |
| 618 | 616 |
|
| 619 | 617 |
if didSave {
|
@@ -645,8 +643,7 @@ final class AppData : ObservableObject {
|
||
| 645 | 643 |
func addBatteryCheckpoint( |
| 646 | 644 |
percent: Double, |
| 647 | 645 |
for sessionID: UUID, |
| 648 |
- measuredEnergyWh: Double?, |
|
| 649 |
- measuredChargeAh: Double? |
|
| 646 |
+ measuredEnergyWh: Double? |
|
| 650 | 647 |
) -> Bool {
|
| 651 | 648 |
guard canAddBatteryCheckpoint(to: sessionID) else {
|
| 652 | 649 |
return false |
@@ -655,8 +652,7 @@ final class AppData : ObservableObject {
|
||
| 655 | 652 |
let didSave = chargeInsightsStore?.addBatteryCheckpoint( |
| 656 | 653 |
percent: percent, |
| 657 | 654 |
for: sessionID, |
| 658 |
- measuredEnergyWh: measuredEnergyWh, |
|
| 659 |
- measuredChargeAh: measuredChargeAh |
|
| 655 |
+ measuredEnergyWh: measuredEnergyWh |
|
| 660 | 656 |
) ?? false |
| 661 | 657 |
|
| 662 | 658 |
if didSave {
|
@@ -1248,27 +1244,9 @@ final class AppData : ObservableObject {
|
||
| 1248 | 1244 |
return storedEnergyWh |
| 1249 | 1245 |
} |
| 1250 | 1246 |
|
| 1251 |
- private func displayedSessionChargeAh(for session: ChargeSessionSummary, on meter: Meter) -> Double {
|
|
| 1252 |
- let storedChargeAh = session.measuredChargeAh |
|
| 1253 |
- guard session.isTrimmed == false else {
|
|
| 1254 |
- return storedChargeAh |
|
| 1255 |
- } |
|
| 1256 |
- guard session.status.isOpen else {
|
|
| 1257 |
- return storedChargeAh |
|
| 1258 |
- } |
|
| 1259 |
- |
|
| 1260 |
- guard session.meterMACAddress == meter.btSerial.macAddress.description else {
|
|
| 1261 |
- return storedChargeAh |
|
| 1262 |
- } |
|
| 1263 |
- |
|
| 1264 |
- if let baselineChargeAh = session.meterChargeBaselineAh {
|
|
| 1265 |
- return max(storedChargeAh, max(meter.recordedAH - baselineChargeAh, 0)) |
|
| 1266 |
- } |
|
| 1267 |
- |
|
| 1268 |
- return storedChargeAh |
|
| 1269 |
- } |
|
| 1270 | 1247 |
} |
| 1271 | 1248 |
|
| 1249 |
+ |
|
| 1272 | 1250 |
extension AppData.MeterSummary {
|
| 1273 | 1251 |
var tint: Color {
|
| 1274 | 1252 |
switch modelSummary {
|
@@ -544,7 +544,6 @@ struct ChargeCheckpointSummary: Identifiable, Hashable {
|
||
| 544 | 544 |
let timestamp: Date |
| 545 | 545 |
let batteryPercent: Double |
| 546 | 546 |
let measuredEnergyWh: Double |
| 547 |
- let measuredChargeAh: Double |
|
| 548 | 547 |
let currentAmps: Double |
| 549 | 548 |
let voltageVolts: Double? |
| 550 | 549 |
let label: String? |
@@ -608,7 +607,6 @@ struct ChargeSessionSampleSummary: Identifiable, Hashable {
|
||
| 608 | 607 |
let averageVoltageVolts: Double? |
| 609 | 608 |
let averagePowerWatts: Double |
| 610 | 609 |
let measuredEnergyWh: Double |
| 611 |
- let measuredChargeAh: Double |
|
| 612 | 610 |
let sampleCount: Int |
| 613 | 611 |
|
| 614 | 612 |
var id: String {
|
@@ -634,9 +632,7 @@ struct ChargeSessionSummary: Identifiable, Hashable {
|
||
| 634 | 632 |
let autoStopEnabled: Bool |
| 635 | 633 |
let measuredEnergyWh: Double |
| 636 | 634 |
let effectiveBatteryEnergyWh: Double? |
| 637 |
- let measuredChargeAh: Double |
|
| 638 | 635 |
let meterEnergyBaselineWh: Double? |
| 639 |
- let meterChargeBaselineAh: Double? |
|
| 640 | 636 |
let meterDurationBaselineSeconds: Double? |
| 641 | 637 |
let meterLastDurationSeconds: Double? |
| 642 | 638 |
let minimumObservedCurrentAmps: Double? |
@@ -733,7 +729,6 @@ struct ChargeSessionSummary: Identifiable, Hashable {
|
||
| 733 | 729 |
var hasSavableChargeData: Bool {
|
| 734 | 730 |
hasObservedChargeFlow |
| 735 | 731 |
|| measuredEnergyWh > 0 |
| 736 |
- || measuredChargeAh > 0 |
|
| 737 | 732 |
|| (maximumObservedCurrentAmps ?? 0) > 0 |
| 738 | 733 |
|| (maximumObservedPowerWatts ?? 0) > 0 |
| 739 | 734 |
|| !aggregatedSamples.isEmpty |
@@ -812,7 +807,6 @@ struct CapacityTrendPoint: Identifiable, Hashable {
|
||
| 812 | 807 |
struct TypicalChargeCurvePoint: Identifiable, Hashable {
|
| 813 | 808 |
let percentBin: Int |
| 814 | 809 |
let averageEnergyWh: Double |
| 815 |
- let averageChargeAh: Double |
|
| 816 | 810 |
let sampleCount: Int |
| 817 | 811 |
|
| 818 | 812 |
var id: Int { percentBin }
|
@@ -707,8 +707,7 @@ final class ChargeInsightsStore {
|
||
| 707 | 707 |
func addBatteryCheckpoint( |
| 708 | 708 |
percent: Double, |
| 709 | 709 |
for meterMACAddress: String, |
| 710 |
- measuredEnergyWh: Double? = nil, |
|
| 711 |
- measuredChargeAh: Double? = nil |
|
| 710 |
+ measuredEnergyWh: Double? = nil |
|
| 712 | 711 |
) -> Bool {
|
| 713 | 712 |
guard percent.isFinite, percent >= 0, percent <= 100 else {
|
| 714 | 713 |
return false |
@@ -723,7 +722,6 @@ final class ChargeInsightsStore {
|
||
| 723 | 722 |
didSave = addBatteryCheckpoint( |
| 724 | 723 |
percent: percent, |
| 725 | 724 |
measuredEnergyWh: measuredEnergyWh, |
| 726 |
- measuredChargeAh: measuredChargeAh, |
|
| 727 | 725 |
flag: .intermediate, |
| 728 | 726 |
to: session |
| 729 | 727 |
) |
@@ -735,8 +733,7 @@ final class ChargeInsightsStore {
|
||
| 735 | 733 |
func addBatteryCheckpoint( |
| 736 | 734 |
percent: Double, |
| 737 | 735 |
for sessionID: UUID, |
| 738 |
- measuredEnergyWh: Double? = nil, |
|
| 739 |
- measuredChargeAh: Double? = nil |
|
| 736 |
+ measuredEnergyWh: Double? = nil |
|
| 740 | 737 |
) -> Bool {
|
| 741 | 738 |
guard percent.isFinite, percent >= 0, percent <= 100 else {
|
| 742 | 739 |
return false |
@@ -751,7 +748,6 @@ final class ChargeInsightsStore {
|
||
| 751 | 748 |
didSave = addBatteryCheckpoint( |
| 752 | 749 |
percent: percent, |
| 753 | 750 |
measuredEnergyWh: measuredEnergyWh, |
| 754 |
- measuredChargeAh: measuredChargeAh, |
|
| 755 | 751 |
flag: .intermediate, |
| 756 | 752 |
to: session |
| 757 | 753 |
) |
@@ -2027,7 +2023,6 @@ final class ChargeInsightsStore {
|
||
| 2027 | 2023 |
flag: ChargeCheckpointFlag, |
| 2028 | 2024 |
timestamp: Date = Date(), |
| 2029 | 2025 |
measuredEnergyWhOverride: Double? = nil, |
| 2030 |
- measuredChargeAhOverride: Double? = nil, |
|
| 2031 | 2026 |
to session: NSManagedObject |
| 2032 | 2027 |
) -> String? {
|
| 2033 | 2028 |
guard |
@@ -2042,15 +2037,12 @@ final class ChargeInsightsStore {
|
||
| 2042 | 2037 |
let checkpointEnergyWh = measuredEnergyWhOverride |
| 2043 | 2038 |
?? optionalDoubleValue(session, key: "effectiveBatteryEnergyWh") |
| 2044 | 2039 |
?? doubleValue(session, key: "measuredEnergyWh") |
| 2045 |
- let checkpointChargeAh = measuredChargeAhOverride |
|
| 2046 |
- ?? doubleValue(session, key: "measuredChargeAh") |
|
| 2047 | 2040 |
checkpoint.setValue(UUID().uuidString, forKey: "id") |
| 2048 | 2041 |
checkpoint.setValue(sessionID, forKey: "sessionID") |
| 2049 | 2042 |
checkpoint.setValue(chargedDeviceID, forKey: "chargedDeviceID") |
| 2050 | 2043 |
checkpoint.setValue(timestamp, forKey: "timestamp") |
| 2051 | 2044 |
checkpoint.setValue(percent, forKey: "batteryPercent") |
| 2052 | 2045 |
checkpoint.setValue(checkpointEnergyWh, forKey: "measuredEnergyWh") |
| 2053 |
- checkpoint.setValue(checkpointChargeAh, forKey: "measuredChargeAh") |
|
| 2054 | 2046 |
checkpoint.setValue(doubleValue(session, key: "lastObservedCurrentAmps"), forKey: "currentAmps") |
| 2055 | 2047 |
checkpoint.setValue( |
| 2056 | 2048 |
chargingTransportMode(for: session) == .wired ? optionalDoubleValue(session, key: "lastObservedVoltageVolts") : nil, |
@@ -2095,7 +2087,6 @@ final class ChargeInsightsStore {
|
||
| 2095 | 2087 |
private func addBatteryCheckpoint( |
| 2096 | 2088 |
percent: Double, |
| 2097 | 2089 |
measuredEnergyWh: Double? = nil, |
| 2098 |
- measuredChargeAh: Double? = nil, |
|
| 2099 | 2090 |
flag: ChargeCheckpointFlag, |
| 2100 | 2091 |
to session: NSManagedObject, |
| 2101 | 2092 |
timestamp: Date = Date() |
@@ -2103,16 +2094,12 @@ final class ChargeInsightsStore {
|
||
| 2103 | 2094 |
if let measuredEnergyWh, measuredEnergyWh.isFinite {
|
| 2104 | 2095 |
session.setValue(max(measuredEnergyWh, 0), forKey: "measuredEnergyWh") |
| 2105 | 2096 |
} |
| 2106 |
- if let measuredChargeAh, measuredChargeAh.isFinite {
|
|
| 2107 |
- session.setValue(max(measuredChargeAh, 0), forKey: "measuredChargeAh") |
|
| 2108 |
- } |
|
| 2109 | 2097 |
|
| 2110 | 2098 |
guard let chargedDeviceID = insertBatteryCheckpoint( |
| 2111 | 2099 |
percent: percent, |
| 2112 | 2100 |
flag: flag, |
| 2113 | 2101 |
timestamp: timestamp, |
| 2114 | 2102 |
measuredEnergyWhOverride: measuredEnergyWh, |
| 2115 |
- measuredChargeAhOverride: measuredChargeAh, |
|
| 2116 | 2103 |
to: session |
| 2117 | 2104 |
) else {
|
| 2118 | 2105 |
return false |
@@ -2285,7 +2272,6 @@ final class ChargeInsightsStore {
|
||
| 2285 | 2272 |
|
| 2286 | 2273 |
private func buildTypicalCurve(from sessions: [ChargeSessionSummary]) -> [TypicalChargeCurvePoint] {
|
| 2287 | 2274 |
var groupedEnergyByBin: [Int: [Double]] = [:] |
| 2288 |
- var groupedChargeByBin: [Int: [Double]] = [:] |
|
| 2289 | 2275 |
|
| 2290 | 2276 |
for session in sessions where session.status == .completed {
|
| 2291 | 2277 |
let anchors = normalizedTypicalCurveAnchors(for: session) |
@@ -2294,46 +2280,36 @@ final class ChargeInsightsStore {
|
||
| 2294 | 2280 |
} |
| 2295 | 2281 |
|
| 2296 | 2282 |
for percentBin in stride(from: 0, through: 100, by: 10) {
|
| 2297 |
- guard let interpolatedPoint = interpolatedTypicalCurvePoint( |
|
| 2283 |
+ guard let energyWh = interpolatedTypicalCurvePoint( |
|
| 2298 | 2284 |
for: Double(percentBin), |
| 2299 | 2285 |
anchors: anchors |
| 2300 | 2286 |
) else {
|
| 2301 | 2287 |
continue |
| 2302 | 2288 |
} |
| 2303 | 2289 |
|
| 2304 |
- groupedEnergyByBin[percentBin, default: []].append(interpolatedPoint.energyWh) |
|
| 2305 |
- groupedChargeByBin[percentBin, default: []].append(interpolatedPoint.chargeAh) |
|
| 2290 |
+ groupedEnergyByBin[percentBin, default: []].append(energyWh) |
|
| 2306 | 2291 |
} |
| 2307 | 2292 |
} |
| 2308 | 2293 |
|
| 2309 | 2294 |
let averagedPoints = groupedEnergyByBin.keys.sorted().compactMap { percentBin -> TypicalChargeCurvePoint? in
|
| 2310 |
- guard |
|
| 2311 |
- let energies = groupedEnergyByBin[percentBin], |
|
| 2312 |
- let charges = groupedChargeByBin[percentBin], |
|
| 2313 |
- !energies.isEmpty, |
|
| 2314 |
- !charges.isEmpty |
|
| 2315 |
- else {
|
|
| 2295 |
+ guard let energies = groupedEnergyByBin[percentBin], !energies.isEmpty else {
|
|
| 2316 | 2296 |
return nil |
| 2317 | 2297 |
} |
| 2318 | 2298 |
|
| 2319 | 2299 |
return TypicalChargeCurvePoint( |
| 2320 | 2300 |
percentBin: percentBin, |
| 2321 | 2301 |
averageEnergyWh: energies.reduce(0, +) / Double(energies.count), |
| 2322 |
- averageChargeAh: charges.reduce(0, +) / Double(charges.count), |
|
| 2323 |
- sampleCount: min(energies.count, charges.count) |
|
| 2302 |
+ sampleCount: energies.count |
|
| 2324 | 2303 |
) |
| 2325 | 2304 |
} |
| 2326 | 2305 |
|
| 2327 | 2306 |
var runningMaximumEnergyWh = 0.0 |
| 2328 |
- var runningMaximumChargeAh = 0.0 |
|
| 2329 | 2307 |
|
| 2330 | 2308 |
return averagedPoints.map { point in
|
| 2331 | 2309 |
runningMaximumEnergyWh = max(runningMaximumEnergyWh, point.averageEnergyWh) |
| 2332 |
- runningMaximumChargeAh = max(runningMaximumChargeAh, point.averageChargeAh) |
|
| 2333 | 2310 |
return TypicalChargeCurvePoint( |
| 2334 | 2311 |
percentBin: point.percentBin, |
| 2335 | 2312 |
averageEnergyWh: runningMaximumEnergyWh, |
| 2336 |
- averageChargeAh: runningMaximumChargeAh, |
|
| 2337 | 2313 |
sampleCount: point.sampleCount |
| 2338 | 2314 |
) |
| 2339 | 2315 |
} |
@@ -2341,29 +2317,25 @@ final class ChargeInsightsStore {
|
||
| 2341 | 2317 |
|
| 2342 | 2318 |
private func normalizedTypicalCurveAnchors( |
| 2343 | 2319 |
for session: ChargeSessionSummary |
| 2344 |
- ) -> [(percent: Double, energyWh: Double, chargeAh: Double)] {
|
|
| 2320 |
+ ) -> [(percent: Double, energyWh: Double)] {
|
|
| 2345 | 2321 |
struct Anchor {
|
| 2346 | 2322 |
let percent: Double |
| 2347 | 2323 |
let energyWh: Double |
| 2348 |
- let chargeAh: Double |
|
| 2349 | 2324 |
let timestamp: Date |
| 2350 | 2325 |
} |
| 2351 | 2326 |
|
| 2352 | 2327 |
var anchors: [Anchor] = session.checkpoints.compactMap { checkpoint in
|
| 2353 | 2328 |
guard checkpoint.batteryPercent.isFinite, |
| 2354 | 2329 |
checkpoint.measuredEnergyWh.isFinite, |
| 2355 |
- checkpoint.measuredChargeAh.isFinite, |
|
| 2356 | 2330 |
checkpoint.batteryPercent >= 0, |
| 2357 | 2331 |
checkpoint.batteryPercent <= 100, |
| 2358 |
- checkpoint.measuredEnergyWh >= 0, |
|
| 2359 |
- checkpoint.measuredChargeAh >= 0 else {
|
|
| 2332 |
+ checkpoint.measuredEnergyWh >= 0 else {
|
|
| 2360 | 2333 |
return nil |
| 2361 | 2334 |
} |
| 2362 | 2335 |
|
| 2363 | 2336 |
return Anchor( |
| 2364 | 2337 |
percent: checkpoint.batteryPercent, |
| 2365 | 2338 |
energyWh: checkpoint.measuredEnergyWh, |
| 2366 |
- chargeAh: checkpoint.measuredChargeAh, |
|
| 2367 | 2339 |
timestamp: checkpoint.timestamp |
| 2368 | 2340 |
) |
| 2369 | 2341 |
} |
@@ -2376,7 +2348,6 @@ final class ChargeInsightsStore {
|
||
| 2376 | 2348 |
Anchor( |
| 2377 | 2349 |
percent: startBatteryPercent, |
| 2378 | 2350 |
energyWh: 0, |
| 2379 |
- chargeAh: 0, |
|
| 2380 | 2351 |
timestamp: session.startedAt |
| 2381 | 2352 |
) |
| 2382 | 2353 |
) |
@@ -2390,7 +2361,6 @@ final class ChargeInsightsStore {
|
||
| 2390 | 2361 |
Anchor( |
| 2391 | 2362 |
percent: endBatteryPercent, |
| 2392 | 2363 |
energyWh: session.effectiveBatteryEnergyWh ?? session.measuredEnergyWh, |
| 2393 |
- chargeAh: session.measuredChargeAh, |
|
| 2394 | 2364 |
timestamp: session.endedAt ?? session.lastObservedAt |
| 2395 | 2365 |
) |
| 2396 | 2366 |
) |
@@ -2406,41 +2376,37 @@ final class ChargeInsightsStore {
|
||
| 2406 | 2376 |
return lhs.timestamp < rhs.timestamp |
| 2407 | 2377 |
} |
| 2408 | 2378 |
|
| 2409 |
- var collapsedAnchors: [(percent: Double, energyWh: Double, chargeAh: Double)] = [] |
|
| 2379 |
+ var collapsedAnchors: [(percent: Double, energyWh: Double)] = [] |
|
| 2410 | 2380 |
|
| 2411 | 2381 |
for anchor in sortedAnchors {
|
| 2412 | 2382 |
if let lastIndex = collapsedAnchors.indices.last, |
| 2413 | 2383 |
abs(collapsedAnchors[lastIndex].percent - anchor.percent) < 0.000_1 {
|
| 2414 | 2384 |
collapsedAnchors[lastIndex] = ( |
| 2415 | 2385 |
percent: collapsedAnchors[lastIndex].percent, |
| 2416 |
- energyWh: max(collapsedAnchors[lastIndex].energyWh, anchor.energyWh), |
|
| 2417 |
- chargeAh: max(collapsedAnchors[lastIndex].chargeAh, anchor.chargeAh) |
|
| 2386 |
+ energyWh: max(collapsedAnchors[lastIndex].energyWh, anchor.energyWh) |
|
| 2418 | 2387 |
) |
| 2419 | 2388 |
} else {
|
| 2420 | 2389 |
collapsedAnchors.append( |
| 2421 |
- (percent: anchor.percent, energyWh: anchor.energyWh, chargeAh: anchor.chargeAh) |
|
| 2390 |
+ (percent: anchor.percent, energyWh: anchor.energyWh) |
|
| 2422 | 2391 |
) |
| 2423 | 2392 |
} |
| 2424 | 2393 |
} |
| 2425 | 2394 |
|
| 2426 | 2395 |
var runningMaximumEnergyWh = 0.0 |
| 2427 |
- var runningMaximumChargeAh = 0.0 |
|
| 2428 | 2396 |
|
| 2429 | 2397 |
return collapsedAnchors.map { anchor in
|
| 2430 | 2398 |
runningMaximumEnergyWh = max(runningMaximumEnergyWh, anchor.energyWh) |
| 2431 |
- runningMaximumChargeAh = max(runningMaximumChargeAh, anchor.chargeAh) |
|
| 2432 | 2399 |
return ( |
| 2433 | 2400 |
percent: anchor.percent, |
| 2434 |
- energyWh: runningMaximumEnergyWh, |
|
| 2435 |
- chargeAh: runningMaximumChargeAh |
|
| 2401 |
+ energyWh: runningMaximumEnergyWh |
|
| 2436 | 2402 |
) |
| 2437 | 2403 |
} |
| 2438 | 2404 |
} |
| 2439 | 2405 |
|
| 2440 | 2406 |
private func interpolatedTypicalCurvePoint( |
| 2441 | 2407 |
for percent: Double, |
| 2442 |
- anchors: [(percent: Double, energyWh: Double, chargeAh: Double)] |
|
| 2443 |
- ) -> (energyWh: Double, chargeAh: Double)? {
|
|
| 2408 |
+ anchors: [(percent: Double, energyWh: Double)] |
|
| 2409 |
+ ) -> Double? {
|
|
| 2444 | 2410 |
guard |
| 2445 | 2411 |
let firstAnchor = anchors.first, |
| 2446 | 2412 |
let lastAnchor = anchors.last, |
@@ -2451,7 +2417,7 @@ final class ChargeInsightsStore {
|
||
| 2451 | 2417 |
} |
| 2452 | 2418 |
|
| 2453 | 2419 |
if let exactAnchor = anchors.first(where: { abs($0.percent - percent) < 0.000_1 }) {
|
| 2454 |
- return (energyWh: exactAnchor.energyWh, chargeAh: exactAnchor.chargeAh) |
|
| 2420 |
+ return exactAnchor.energyWh |
|
| 2455 | 2421 |
} |
| 2456 | 2422 |
|
| 2457 | 2423 |
guard let upperIndex = anchors.firstIndex(where: { $0.percent > percent }),
|
@@ -2467,9 +2433,7 @@ final class ChargeInsightsStore {
|
||
| 2467 | 2433 |
} |
| 2468 | 2434 |
|
| 2469 | 2435 |
let ratio = (percent - lowerAnchor.percent) / span |
| 2470 |
- let energyWh = lowerAnchor.energyWh + ((upperAnchor.energyWh - lowerAnchor.energyWh) * ratio) |
|
| 2471 |
- let chargeAh = lowerAnchor.chargeAh + ((upperAnchor.chargeAh - lowerAnchor.chargeAh) * ratio) |
|
| 2472 |
- return (energyWh: energyWh, chargeAh: chargeAh) |
|
| 2436 |
+ return lowerAnchor.energyWh + ((upperAnchor.energyWh - lowerAnchor.energyWh) * ratio) |
|
| 2473 | 2437 |
} |
| 2474 | 2438 |
|
| 2475 | 2439 |
private func makeSessionSummary( |
@@ -2518,9 +2482,7 @@ final class ChargeInsightsStore {
|
||
| 2518 | 2482 |
autoStopEnabled: boolValue(object, key: "autoStopEnabled"), |
| 2519 | 2483 |
measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"), |
| 2520 | 2484 |
effectiveBatteryEnergyWh: optionalDoubleValue(object, key: "effectiveBatteryEnergyWh"), |
| 2521 |
- measuredChargeAh: doubleValue(object, key: "measuredChargeAh"), |
|
| 2522 | 2485 |
meterEnergyBaselineWh: optionalDoubleValue(object, key: "meterEnergyBaselineWh"), |
| 2523 |
- meterChargeBaselineAh: optionalDoubleValue(object, key: "meterChargeBaselineAh"), |
|
| 2524 | 2486 |
meterDurationBaselineSeconds: optionalDoubleValue(object, key: "meterDurationBaselineSeconds"), |
| 2525 | 2487 |
meterLastDurationSeconds: optionalDoubleValue(object, key: "meterLastDurationSeconds"), |
| 2526 | 2488 |
minimumObservedCurrentAmps: optionalDoubleValue(object, key: "minimumObservedCurrentAmps"), |
@@ -2572,7 +2534,6 @@ final class ChargeInsightsStore {
|
||
| 2572 | 2534 |
timestamp: timestamp, |
| 2573 | 2535 |
batteryPercent: doubleValue(object, key: "batteryPercent"), |
| 2574 | 2536 |
measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"), |
| 2575 |
- measuredChargeAh: doubleValue(object, key: "measuredChargeAh"), |
|
| 2576 | 2537 |
currentAmps: doubleValue(object, key: "currentAmps"), |
| 2577 | 2538 |
voltageVolts: optionalDoubleValue(object, key: "voltageVolts"), |
| 2578 | 2539 |
label: stringValue(object, key: "label") |
@@ -2597,7 +2558,6 @@ final class ChargeInsightsStore {
|
||
| 2597 | 2558 |
averageVoltageVolts: optionalDoubleValue(object, key: "averageVoltageVolts"), |
| 2598 | 2559 |
averagePowerWatts: doubleValue(object, key: "averagePowerWatts"), |
| 2599 | 2560 |
measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"), |
| 2600 |
- measuredChargeAh: doubleValue(object, key: "measuredChargeAh"), |
|
| 2601 | 2561 |
sampleCount: Int(optionalInt16Value(object, key: "sampleCount") ?? 0) |
| 2602 | 2562 |
) |
| 2603 | 2563 |
} |
@@ -1011,12 +1011,6 @@ class Meter : NSObject, ObservableObject, Identifiable {
|
||
| 1011 | 1011 |
didChange = true |
| 1012 | 1012 |
} |
| 1013 | 1013 |
|
| 1014 |
- let resolvedChargeAH = max(chargeRecordAH, activeSession.measuredChargeAh) |
|
| 1015 |
- if resolvedChargeAH != chargeRecordAH {
|
|
| 1016 |
- chargeRecordAH = resolvedChargeAH |
|
| 1017 |
- didChange = true |
|
| 1018 |
- } |
|
| 1019 |
- |
|
| 1020 | 1014 |
let resolvedChargeWH = max(chargeRecordWH, activeSession.measuredEnergyWh) |
| 1021 | 1015 |
if resolvedChargeWH != chargeRecordWH {
|
| 1022 | 1016 |
chargeRecordWH = resolvedChargeWH |
@@ -247,7 +247,6 @@ struct ChargeSessionDetailView: View {
|
||
| 247 | 247 |
chargedDevice: ChargedDeviceSummary |
| 248 | 248 |
) -> some View {
|
| 249 | 249 |
let displayedEnergyWh = displayedSessionEnergyWh(for: session) |
| 250 |
- let displayedChargeAh = displayedSessionChargeAh(for: session) |
|
| 251 | 250 |
let batteryPrediction = chargedDevice.batteryLevelPrediction( |
| 252 | 251 |
for: session, |
| 253 | 252 |
effectiveEnergyWhOverride: displayedEnergyWh |
@@ -316,7 +315,6 @@ struct ChargeSessionDetailView: View {
|
||
| 316 | 315 |
canDeleteCheckpoint: true, |
| 317 | 316 |
requirementMessage: appData.batteryCheckpointCaptureRequirementMessage(for: session.id), |
| 318 | 317 |
effectiveEnergyWhOverride: displayedEnergyWh, |
| 319 |
- measuredChargeAhOverride: displayedChargeAh, |
|
| 320 | 318 |
onDelete: { checkpoint in
|
| 321 | 319 |
pendingCheckpointDeletion = checkpoint |
| 322 | 320 |
} |
@@ -330,8 +328,7 @@ struct ChargeSessionDetailView: View {
|
||
| 330 | 328 |
if showingStopConfirm {
|
| 331 | 329 |
stopConfirmPanel( |
| 332 | 330 |
session: session, |
| 333 |
- displayedEnergyWh: displayedEnergyWh, |
|
| 334 |
- displayedChargeAh: displayedChargeAh |
|
| 331 |
+ displayedEnergyWh: displayedEnergyWh |
|
| 335 | 332 |
) |
| 336 | 333 |
} else {
|
| 337 | 334 |
monitoringActionRow(session) |
@@ -345,46 +342,87 @@ struct ChargeSessionDetailView: View {
|
||
| 345 | 342 |
_ session: ChargeSessionSummary, |
| 346 | 343 |
chargedDevice: ChargedDeviceSummary |
| 347 | 344 |
) -> some View {
|
| 348 |
- MeterInfoCardView(title: session.status.isOpen ? "Open Session" : "Overview", tint: statusTint(for: session)) {
|
|
| 349 |
- MeterInfoRowView(label: "Device", value: chargedDevice.name) |
|
| 350 |
- MeterInfoRowView(label: "Status", value: session.status.title) |
|
| 351 |
- MeterInfoRowView(label: "Started", value: session.startedAt.format()) |
|
| 352 |
- if let endedAt = session.endedAt {
|
|
| 353 |
- MeterInfoRowView(label: "Ended", value: endedAt.format()) |
|
| 354 |
- } |
|
| 355 |
- MeterInfoRowView(label: "Duration", value: sessionDurationText(session)) |
|
| 356 |
- MeterInfoRowView(label: "Charging Type", value: session.chargingTransportMode.title) |
|
| 357 |
- MeterInfoRowView(label: "Charging Mode", value: session.chargingStateMode.title) |
|
| 358 |
- MeterInfoRowView(label: "Source", value: session.sourceMode.title) |
|
| 359 |
- MeterInfoRowView(label: "Auto Stop", value: autoStopDescription(for: session)) |
|
| 360 |
- if session.isTrimmed {
|
|
| 361 |
- MeterInfoRowView(label: "Trim Start", value: session.effectiveTrimStart.format()) |
|
| 362 |
- MeterInfoRowView(label: "Trim End", value: session.effectiveTrimEnd.format()) |
|
| 363 |
- } |
|
| 364 |
- if let meterName = session.meterName {
|
|
| 365 |
- MeterInfoRowView(label: "Meter", value: meterName) |
|
| 366 |
- } else if let meterMACAddress = session.meterMACAddress {
|
|
| 367 |
- MeterInfoRowView(label: "Meter", value: meterMACAddress) |
|
| 368 |
- } |
|
| 369 |
- if let meterModel = session.meterModel {
|
|
| 370 |
- MeterInfoRowView(label: "Meter Model", value: meterModel) |
|
| 345 |
+ MeterInfoCardView( |
|
| 346 |
+ title: session.status.isOpen ? "Open Session" : "Overview", |
|
| 347 |
+ tint: statusTint(for: session), |
|
| 348 |
+ isCollapsible: true |
|
| 349 |
+ ) {
|
|
| 350 |
+ VStack(alignment: .leading, spacing: 10) {
|
|
| 351 |
+ MeterInfoRowView(label: "Device", value: chargedDevice.name) |
|
| 352 |
+ |
|
| 353 |
+ Divider() |
|
| 354 |
+ |
|
| 355 |
+ HStack(alignment: .top, spacing: 12) {
|
|
| 356 |
+ overviewStatCell(label: "Started", value: session.startedAt.format()) |
|
| 357 |
+ if let endedAt = session.endedAt {
|
|
| 358 |
+ overviewStatCell(label: "Ended", value: endedAt.format()) |
|
| 359 |
+ } |
|
| 360 |
+ } |
|
| 361 |
+ |
|
| 362 |
+ HStack(alignment: .top, spacing: 12) {
|
|
| 363 |
+ overviewStatCell(label: "Duration", value: sessionDurationText(session)) |
|
| 364 |
+ overviewStatCell(label: "Status", value: session.status.title) |
|
| 365 |
+ } |
|
| 366 |
+ |
|
| 367 |
+ Divider() |
|
| 368 |
+ |
|
| 369 |
+ HStack(alignment: .top, spacing: 12) {
|
|
| 370 |
+ overviewStatCell(label: "Charging Type", value: session.chargingTransportMode.title) |
|
| 371 |
+ overviewStatCell(label: "Charging Mode", value: session.chargingStateMode.title) |
|
| 372 |
+ } |
|
| 373 |
+ |
|
| 374 |
+ HStack(alignment: .top, spacing: 12) {
|
|
| 375 |
+ overviewStatCell(label: "Source", value: session.sourceMode.title) |
|
| 376 |
+ overviewStatCell(label: "Auto Stop", value: autoStopDescription(for: session)) |
|
| 377 |
+ } |
|
| 378 |
+ |
|
| 379 |
+ if session.isTrimmed {
|
|
| 380 |
+ Divider() |
|
| 381 |
+ HStack(alignment: .top, spacing: 12) {
|
|
| 382 |
+ overviewStatCell(label: "Trim Start", value: session.effectiveTrimStart.format()) |
|
| 383 |
+ overviewStatCell(label: "Trim End", value: session.effectiveTrimEnd.format()) |
|
| 384 |
+ } |
|
| 385 |
+ } |
|
| 386 |
+ |
|
| 387 |
+ let meterLabel: String? = session.meterName ?? session.meterMACAddress |
|
| 388 |
+ if meterLabel != nil || session.meterModel != nil {
|
|
| 389 |
+ Divider() |
|
| 390 |
+ HStack(alignment: .top, spacing: 12) {
|
|
| 391 |
+ if let label = meterLabel {
|
|
| 392 |
+ overviewStatCell(label: "Meter", value: label) |
|
| 393 |
+ } |
|
| 394 |
+ if let model = session.meterModel {
|
|
| 395 |
+ overviewStatCell(label: "Meter Model", value: model) |
|
| 396 |
+ } |
|
| 397 |
+ } |
|
| 398 |
+ } |
|
| 371 | 399 |
} |
| 372 | 400 |
} |
| 373 | 401 |
} |
| 374 | 402 |
|
| 403 |
+ private func overviewStatCell(label: String, value: String) -> some View {
|
|
| 404 |
+ VStack(alignment: .leading, spacing: 2) {
|
|
| 405 |
+ Text(label) |
|
| 406 |
+ .font(.caption2) |
|
| 407 |
+ .foregroundColor(.secondary) |
|
| 408 |
+ Text(value) |
|
| 409 |
+ .font(.footnote.weight(.medium)) |
|
| 410 |
+ .monospacedDigit() |
|
| 411 |
+ } |
|
| 412 |
+ .frame(maxWidth: .infinity, alignment: .leading) |
|
| 413 |
+ } |
|
| 414 |
+ |
|
| 375 | 415 |
private func energyCard( |
| 376 | 416 |
_ session: ChargeSessionSummary, |
| 377 | 417 |
chargedDevice: ChargedDeviceSummary |
| 378 | 418 |
) -> some View {
|
| 379 | 419 |
let displayedEnergyWh = displayedSessionEnergyWh(for: session) |
| 380 |
- let displayedChargeAh = displayedSessionChargeAh(for: session) |
|
| 381 | 420 |
|
| 382 |
- return MeterInfoCardView(title: "Energy", tint: .teal) {
|
|
| 421 |
+ return MeterInfoCardView(title: "Energy", tint: .teal, isCollapsible: true) {
|
|
| 383 | 422 |
MeterInfoRowView(label: "Battery Energy", value: "\(displayedEnergyWh.format(decimalDigits: 3)) Wh") |
| 384 | 423 |
if abs(displayedEnergyWh - session.measuredEnergyWh) > 0.01 {
|
| 385 | 424 |
MeterInfoRowView(label: "Measured Energy", value: "\(session.measuredEnergyWh.format(decimalDigits: 3)) Wh") |
| 386 | 425 |
} |
| 387 |
- MeterInfoRowView(label: "Measured Charge", value: "\(displayedChargeAh.format(decimalDigits: 3)) Ah") |
|
| 388 | 426 |
if let effectiveBatteryEnergyWh = session.effectiveBatteryEnergyWh, |
| 389 | 427 |
abs(effectiveBatteryEnergyWh - session.measuredEnergyWh) > 0.01 {
|
| 390 | 428 |
MeterInfoRowView(label: "Effective Battery Energy", value: "\(effectiveBatteryEnergyWh.format(decimalDigits: 3)) Wh") |
@@ -413,7 +451,7 @@ struct ChargeSessionDetailView: View {
|
||
| 413 | 451 |
_ session: ChargeSessionSummary, |
| 414 | 452 |
chargedDevice: ChargedDeviceSummary |
| 415 | 453 |
) -> some View {
|
| 416 |
- MeterInfoCardView(title: "Observed Metrics", tint: .blue) {
|
|
| 454 |
+ MeterInfoCardView(title: "Observed Metrics", tint: .blue, isCollapsible: true, initiallyExpanded: false) {
|
|
| 417 | 455 |
if let minimumObservedCurrentAmps = session.minimumObservedCurrentAmps {
|
| 418 | 456 |
MeterInfoRowView(label: "Min Current", value: "\(minimumObservedCurrentAmps.format(decimalDigits: 2)) A") |
| 419 | 457 |
} |
@@ -443,13 +481,12 @@ struct ChargeSessionDetailView: View {
|
||
| 443 | 481 |
chargedDevice: ChargedDeviceSummary |
| 444 | 482 |
) -> some View {
|
| 445 | 483 |
let displayedEnergyWh = displayedSessionEnergyWh(for: session) |
| 446 |
- let displayedChargeAh = displayedSessionChargeAh(for: session) |
|
| 447 | 484 |
let batteryPrediction = chargedDevice.batteryLevelPrediction( |
| 448 | 485 |
for: session, |
| 449 | 486 |
effectiveEnergyWhOverride: hasMonitoringControls ? displayedEnergyWh : nil |
| 450 | 487 |
) |
| 451 | 488 |
|
| 452 |
- return MeterInfoCardView(title: "Battery", tint: .orange) {
|
|
| 489 |
+ return MeterInfoCardView(title: "Battery", tint: .orange, isCollapsible: true) {
|
|
| 453 | 490 |
if let startBatteryPercent = session.startBatteryPercent {
|
| 454 | 491 |
MeterInfoRowView(label: "Start Battery", value: "\(startBatteryPercent.format(decimalDigits: 0))%") |
| 455 | 492 |
} |
@@ -484,7 +521,6 @@ struct ChargeSessionDetailView: View {
|
||
| 484 | 521 |
canDeleteCheckpoint: hasMonitoringControls || session.status.isOpen == false, |
| 485 | 522 |
requirementMessage: session.status.isOpen ? appData.batteryCheckpointCaptureRequirementMessage(for: session.id) : nil, |
| 486 | 523 |
effectiveEnergyWhOverride: hasMonitoringControls ? displayedEnergyWh : nil, |
| 487 |
- measuredChargeAhOverride: hasMonitoringControls ? displayedChargeAh : nil, |
|
| 488 | 524 |
onDelete: { checkpoint in
|
| 489 | 525 |
pendingCheckpointDeletion = checkpoint |
| 490 | 526 |
} |
@@ -888,18 +924,15 @@ struct ChargeSessionDetailView: View {
|
||
| 888 | 924 |
|
| 889 | 925 |
private func stopConfirmPanel( |
| 890 | 926 |
session: ChargeSessionSummary, |
| 891 |
- displayedEnergyWh: Double, |
|
| 892 |
- displayedChargeAh: Double |
|
| 927 |
+ displayedEnergyWh: Double |
|
| 893 | 928 |
) -> some View {
|
| 894 | 929 |
let canSave = hasSavableChargeData( |
| 895 | 930 |
session: session, |
| 896 |
- displayedEnergyWh: displayedEnergyWh, |
|
| 897 |
- displayedChargeAh: displayedChargeAh |
|
| 931 |
+ displayedEnergyWh: displayedEnergyWh |
|
| 898 | 932 |
) |
| 899 | 933 |
let saveDisabledReason = saveDisabledReason( |
| 900 | 934 |
session: session, |
| 901 |
- displayedEnergyWh: displayedEnergyWh, |
|
| 902 |
- displayedChargeAh: displayedChargeAh |
|
| 935 |
+ displayedEnergyWh: displayedEnergyWh |
|
| 903 | 936 |
) |
| 904 | 937 |
let isSaveEnabled = saveDisabledReason == nil |
| 905 | 938 |
|
@@ -944,8 +977,7 @@ struct ChargeSessionDetailView: View {
|
||
| 944 | 977 |
Button {
|
| 945 | 978 |
stopSession( |
| 946 | 979 |
session, |
| 947 |
- displayedEnergyWh: displayedEnergyWh, |
|
| 948 |
- displayedChargeAh: displayedChargeAh |
|
| 980 |
+ displayedEnergyWh: displayedEnergyWh |
|
| 949 | 981 |
) |
| 950 | 982 |
} label: {
|
| 951 | 983 |
Label("Save Session", systemImage: "checkmark.circle.fill")
|
@@ -1232,18 +1264,15 @@ struct ChargeSessionDetailView: View {
|
||
| 1232 | 1264 |
|
| 1233 | 1265 |
private func hasSavableChargeData( |
| 1234 | 1266 |
session: ChargeSessionSummary, |
| 1235 |
- displayedEnergyWh: Double, |
|
| 1236 |
- displayedChargeAh: Double |
|
| 1267 |
+ displayedEnergyWh: Double |
|
| 1237 | 1268 |
) -> Bool {
|
| 1238 | 1269 |
session.hasSavableChargeData |
| 1239 | 1270 |
|| displayedEnergyWh > 0 |
| 1240 |
- || displayedChargeAh > 0 |
|
| 1241 | 1271 |
} |
| 1242 | 1272 |
|
| 1243 | 1273 |
private func saveDisabledReason( |
| 1244 | 1274 |
session: ChargeSessionSummary, |
| 1245 |
- displayedEnergyWh: Double, |
|
| 1246 |
- displayedChargeAh: Double |
|
| 1275 |
+ displayedEnergyWh: Double |
|
| 1247 | 1276 |
) -> String? {
|
| 1248 | 1277 |
if finalCheckpointMode == .custom {
|
| 1249 | 1278 |
let trimmed = finalCheckpointText.trimmingCharacters(in: .whitespacesAndNewlines) |
@@ -1257,8 +1286,7 @@ struct ChargeSessionDetailView: View {
|
||
| 1257 | 1286 |
|
| 1258 | 1287 |
guard hasSavableChargeData( |
| 1259 | 1288 |
session: session, |
| 1260 |
- displayedEnergyWh: displayedEnergyWh, |
|
| 1261 |
- displayedChargeAh: displayedChargeAh |
|
| 1289 |
+ displayedEnergyWh: displayedEnergyWh |
|
| 1262 | 1290 |
) else {
|
| 1263 | 1291 |
return "This session has no charging data to save. Discard it instead." |
| 1264 | 1292 |
} |
@@ -1268,15 +1296,13 @@ struct ChargeSessionDetailView: View {
|
||
| 1268 | 1296 |
|
| 1269 | 1297 |
private func stopSession( |
| 1270 | 1298 |
_ session: ChargeSessionSummary, |
| 1271 |
- displayedEnergyWh: Double, |
|
| 1272 |
- displayedChargeAh: Double |
|
| 1299 |
+ displayedEnergyWh: Double |
|
| 1273 | 1300 |
) {
|
| 1274 | 1301 |
stopFailureMessage = nil |
| 1275 | 1302 |
|
| 1276 | 1303 |
if let saveDisabledReason = saveDisabledReason( |
| 1277 | 1304 |
session: session, |
| 1278 |
- displayedEnergyWh: displayedEnergyWh, |
|
| 1279 |
- displayedChargeAh: displayedChargeAh |
|
| 1305 |
+ displayedEnergyWh: displayedEnergyWh |
|
| 1280 | 1306 |
) {
|
| 1281 | 1307 |
stopFailureMessage = saveDisabledReason |
| 1282 | 1308 |
return |
@@ -1352,18 +1378,6 @@ struct ChargeSessionDetailView: View {
|
||
| 1352 | 1378 |
return storedEnergyWh |
| 1353 | 1379 |
} |
| 1354 | 1380 |
|
| 1355 |
- private func displayedSessionChargeAh(for session: ChargeSessionSummary) -> Double {
|
|
| 1356 |
- let storedChargeAh = session.measuredChargeAh |
|
| 1357 |
- guard session.isTrimmed == false else { return storedChargeAh }
|
|
| 1358 |
- guard session.status.isOpen else { return storedChargeAh }
|
|
| 1359 |
- guard let liveMonitoringMeter else { return storedChargeAh }
|
|
| 1360 |
- guard session.meterMACAddress == liveMonitoringMeter.btSerial.macAddress.description else { return storedChargeAh }
|
|
| 1361 |
- if let baselineChargeAh = session.meterChargeBaselineAh {
|
|
| 1362 |
- return max(storedChargeAh, max(liveMonitoringMeter.recordedAH - baselineChargeAh, 0)) |
|
| 1363 |
- } |
|
| 1364 |
- return storedChargeAh |
|
| 1365 |
- } |
|
| 1366 |
- |
|
| 1367 | 1381 |
private func displayedSessionDuration(for session: ChargeSessionSummary) -> TimeInterval {
|
| 1368 | 1382 |
let storedDuration = max(session.effectiveDuration, 0) |
| 1369 | 1383 |
guard session.isTrimmed == false else { return storedDuration }
|
@@ -13,7 +13,6 @@ struct BatteryCheckpointEditorContentView: View {
|
||
| 13 | 13 |
let sessionID: UUID |
| 14 | 14 |
let message: String |
| 15 | 15 |
let effectiveEnergyWhOverride: Double? |
| 16 |
- let measuredChargeAhOverride: Double? |
|
| 17 | 16 |
let onCancel: (() -> Void)? |
| 18 | 17 |
let onSaved: (() -> Void)? |
| 19 | 18 |
let showsHeader: Bool |
@@ -25,7 +24,6 @@ struct BatteryCheckpointEditorContentView: View {
|
||
| 25 | 24 |
sessionID: UUID, |
| 26 | 25 |
message: String, |
| 27 | 26 |
effectiveEnergyWhOverride: Double?, |
| 28 |
- measuredChargeAhOverride: Double?, |
|
| 29 | 27 |
onCancel: (() -> Void)?, |
| 30 | 28 |
onSaved: (() -> Void)?, |
| 31 | 29 |
showsHeader: Bool = true |
@@ -33,7 +31,6 @@ struct BatteryCheckpointEditorContentView: View {
|
||
| 33 | 31 |
self.sessionID = sessionID |
| 34 | 32 |
self.message = message |
| 35 | 33 |
self.effectiveEnergyWhOverride = effectiveEnergyWhOverride |
| 36 |
- self.measuredChargeAhOverride = measuredChargeAhOverride |
|
| 37 | 34 |
self.onCancel = onCancel |
| 38 | 35 |
self.onSaved = onSaved |
| 39 | 36 |
self.showsHeader = showsHeader |
@@ -167,8 +164,7 @@ struct BatteryCheckpointEditorContentView: View {
|
||
| 167 | 164 |
if appData.addBatteryCheckpoint( |
| 168 | 165 |
percent: percent, |
| 169 | 166 |
for: sessionID, |
| 170 |
- measuredEnergyWh: effectiveEnergyWhOverride, |
|
| 171 |
- measuredChargeAh: measuredChargeAhOverride |
|
| 167 |
+ measuredEnergyWh: effectiveEnergyWhOverride |
|
| 172 | 168 |
) {
|
| 173 | 169 |
onSaved?() |
| 174 | 170 |
} |
@@ -183,7 +179,6 @@ struct BatteryCheckpointSectionView: View {
|
||
| 183 | 179 |
let canDeleteCheckpoint: Bool |
| 184 | 180 |
let requirementMessage: String? |
| 185 | 181 |
let effectiveEnergyWhOverride: Double? |
| 186 |
- let measuredChargeAhOverride: Double? |
|
| 187 | 182 |
let onDelete: (ChargeCheckpointSummary) -> Void |
| 188 | 183 |
|
| 189 | 184 |
@State private var showsInlineCheckpointEditor = false |
@@ -211,7 +206,6 @@ struct BatteryCheckpointSectionView: View {
|
||
| 211 | 206 |
sessionID: sessionID, |
| 212 | 207 |
message: message, |
| 213 | 208 |
effectiveEnergyWhOverride: effectiveEnergyWhOverride, |
| 214 |
- measuredChargeAhOverride: measuredChargeAhOverride, |
|
| 215 | 209 |
onCancel: { showsInlineCheckpointEditor = false },
|
| 216 | 210 |
onSaved: { showsInlineCheckpointEditor = false },
|
| 217 | 211 |
showsHeader: false |
@@ -294,7 +288,6 @@ struct BatteryCheckpointEditorSheetView: View {
|
||
| 294 | 288 |
sessionID: activeSession.id, |
| 295 | 289 |
message: "The checkpoint is stored on the active charge session and later used for capacity estimation and the typical charge curve.", |
| 296 | 290 |
effectiveEnergyWhOverride: nil, |
| 297 |
- measuredChargeAhOverride: nil, |
|
| 298 | 291 |
onCancel: { dismiss() },
|
| 299 | 292 |
onSaved: { dismiss() },
|
| 300 | 293 |
showsHeader: true |
@@ -180,7 +180,6 @@ struct ChargeSessionCompletionSheetView: View {
|
||
| 180 | 180 |
guard let session else { return false }
|
| 181 | 181 |
return session.hasSavableChargeData |
| 182 | 182 |
|| displayedSessionEnergyWh(for: session) > 0 |
| 183 |
- || displayedSessionChargeAh(for: session) > 0 |
|
| 184 | 183 |
} |
| 185 | 184 |
|
| 186 | 185 |
private var saveDisabledReason: String? {
|
@@ -252,15 +251,5 @@ struct ChargeSessionCompletionSheetView: View {
|
||
| 252 | 251 |
return storedEnergyWh |
| 253 | 252 |
} |
| 254 | 253 |
|
| 255 |
- private func displayedSessionChargeAh(for session: ChargeSessionSummary) -> Double {
|
|
| 256 |
- let storedChargeAh = session.measuredChargeAh |
|
| 257 |
- guard session.isTrimmed == false else { return storedChargeAh }
|
|
| 258 |
- guard session.status.isOpen else { return storedChargeAh }
|
|
| 259 |
- guard let monitoringMeter else { return storedChargeAh }
|
|
| 260 |
- guard session.meterMACAddress == monitoringMeter.btSerial.macAddress.description else { return storedChargeAh }
|
|
| 261 |
- if let baselineChargeAh = session.meterChargeBaselineAh {
|
|
| 262 |
- return max(storedChargeAh, max(monitoringMeter.recordedAH - baselineChargeAh, 0)) |
|
| 263 |
- } |
|
| 264 |
- return storedChargeAh |
|
| 265 |
- } |
|
| 266 | 254 |
} |
| 255 |
+ |
|
@@ -9,6 +9,8 @@ struct MeterInfoCardView<Content: View, TrailingActions: View>: View {
|
||
| 9 | 9 |
let title: String |
| 10 | 10 |
let infoMessage: String? |
| 11 | 11 |
let tint: Color |
| 12 |
+ let isCollapsible: Bool |
|
| 13 |
+ @State private var isExpanded: Bool |
|
| 12 | 14 |
@ViewBuilder var trailingActions: TrailingActions |
| 13 | 15 |
@ViewBuilder var content: Content |
| 14 | 16 |
|
@@ -16,18 +18,22 @@ struct MeterInfoCardView<Content: View, TrailingActions: View>: View {
|
||
| 16 | 18 |
title: String, |
| 17 | 19 |
infoMessage: String? = nil, |
| 18 | 20 |
tint: Color, |
| 21 |
+ isCollapsible: Bool = false, |
|
| 22 |
+ initiallyExpanded: Bool = true, |
|
| 19 | 23 |
@ViewBuilder trailingActions: () -> TrailingActions = { EmptyView() },
|
| 20 | 24 |
@ViewBuilder content: () -> Content |
| 21 | 25 |
) {
|
| 22 | 26 |
self.title = title |
| 23 | 27 |
self.infoMessage = infoMessage |
| 24 | 28 |
self.tint = tint |
| 29 |
+ self.isCollapsible = isCollapsible |
|
| 30 |
+ self._isExpanded = State(initialValue: initiallyExpanded) |
|
| 25 | 31 |
self.trailingActions = trailingActions() |
| 26 | 32 |
self.content = content() |
| 27 | 33 |
} |
| 28 | 34 |
|
| 29 | 35 |
var body: some View {
|
| 30 |
- VStack(alignment: .leading, spacing: 12) {
|
|
| 36 |
+ VStack(alignment: .leading, spacing: isExpanded ? 12 : 0) {
|
|
| 31 | 37 |
HStack(spacing: 8) {
|
| 32 | 38 |
Text(title) |
| 33 | 39 |
.font(.headline) |
@@ -36,8 +42,26 @@ struct MeterInfoCardView<Content: View, TrailingActions: View>: View {
|
||
| 36 | 42 |
} |
| 37 | 43 |
Spacer(minLength: 0) |
| 38 | 44 |
trailingActions |
| 45 |
+ if isCollapsible {
|
|
| 46 |
+ Image(systemName: "chevron.up") |
|
| 47 |
+ .font(.caption.weight(.semibold)) |
|
| 48 |
+ .foregroundColor(.secondary) |
|
| 49 |
+ .rotationEffect(.degrees(isExpanded ? 0 : -180)) |
|
| 50 |
+ .animation(.easeInOut(duration: 0.2), value: isExpanded) |
|
| 51 |
+ } |
|
| 52 |
+ } |
|
| 53 |
+ .contentShape(Rectangle()) |
|
| 54 |
+ .onTapGesture {
|
|
| 55 |
+ guard isCollapsible else { return }
|
|
| 56 |
+ withAnimation(.easeInOut(duration: 0.25)) {
|
|
| 57 |
+ isExpanded.toggle() |
|
| 58 |
+ } |
|
| 59 |
+ } |
|
| 60 |
+ |
|
| 61 |
+ if isExpanded {
|
|
| 62 |
+ content |
|
| 63 |
+ .transition(.opacity.combined(with: .move(edge: .top))) |
|
| 39 | 64 |
} |
| 40 |
- content |
|
| 41 | 65 |
} |
| 42 | 66 |
.frame(maxWidth: .infinity, alignment: .leading) |
| 43 | 67 |
.padding(18) |