The Battery card now starts collapsed. A thin progress bar is permanently visible in the header showing the charge range (startPercent → endPercent), so the key result is scannable without expanding the card. Preview conditions: - Shown only when startBatteryPercent is known AND an end value exists - endBatteryPercent used when available; batteryPrediction.predictedPercent used as fallback so a missing final checkpoint does not suppress the bar - Estimated end rendered at lower opacity with a ~ prefix Bar anatomy: - Gray capsule track (full width) - Orange fill from start% to end% fraction - White tick marks at each checkpoint position (final checkpoint slightly thicker/brighter than intermediate ones) - start% label on the left, end% (or ~end%) label on the right Implemented with explicit @State on the parent view (isBatteryCardExpanded) rather than MeterInfoCardView to avoid forced-spacing issues with EmptyView in generic VStack context. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@@ -51,6 +51,7 @@ struct ChargeSessionDetailView: View {
|
||
| 51 | 51 |
@State private var draftTargetText = "" |
| 52 | 52 |
@State private var showingStopConfirm = false |
| 53 | 53 |
@State private var finalCheckpointMode: FinalCheckpoint = .skip |
| 54 |
+ @State private var isBatteryCardExpanded = false |
|
| 54 | 55 |
@State private var finalCheckpointText = "" |
| 55 | 56 |
@State private var stopFailureMessage: String? |
| 56 | 57 |
|
@@ -162,6 +163,7 @@ struct ChargeSessionDetailView: View {
|
||
| 162 | 163 |
draftTargetText = "" |
| 163 | 164 |
showingStopConfirm = false |
| 164 | 165 |
finalCheckpointMode = .skip |
| 166 |
+ isBatteryCardExpanded = false |
|
| 165 | 167 |
finalCheckpointText = "" |
| 166 | 168 |
stopFailureMessage = nil |
| 167 | 169 |
syncMonitoringRestore() |
@@ -469,88 +471,187 @@ struct ChargeSessionDetailView: View {
|
||
| 469 | 471 |
effectiveEnergyWhOverride: hasMonitoringControls ? displayedEnergyWh : nil |
| 470 | 472 |
) |
| 471 | 473 |
|
| 472 |
- return MeterInfoCardView(title: "Battery", tint: .orange, isCollapsible: true) {
|
|
| 473 |
- VStack(alignment: .leading, spacing: 10) {
|
|
| 474 |
+ let startPercent = session.startBatteryPercent |
|
| 475 |
+ let endPercent = session.endBatteryPercent ?? batteryPrediction?.predictedPercent |
|
| 476 |
+ let isEstimatedEnd = session.endBatteryPercent == nil && batteryPrediction != nil |
|
| 477 |
+ let showsPreview = startPercent != nil && endPercent != nil |
|
| 474 | 478 |
|
| 475 |
- // Energy |
|
| 476 |
- HStack(alignment: .top, spacing: 12) {
|
|
| 477 |
- overviewStatCell(label: "Battery Energy", value: "\(displayedEnergyWh.format(decimalDigits: 3)) Wh") |
|
| 478 |
- if let capacityEstimateWh = session.capacityEstimateWh {
|
|
| 479 |
- overviewStatCell(label: "Capacity Estimate", value: "\(capacityEstimateWh.format(decimalDigits: 2)) Wh") |
|
| 480 |
- } |
|
| 481 |
- } |
|
| 482 |
- if abs(displayedEnergyWh - session.measuredEnergyWh) > 0.01 {
|
|
| 483 |
- MeterInfoRowView(label: "Measured Energy", value: "\(session.measuredEnergyWh.format(decimalDigits: 3)) Wh") |
|
| 484 |
- } |
|
| 485 |
- if let effectiveBatteryEnergyWh = session.effectiveBatteryEnergyWh, |
|
| 486 |
- abs(effectiveBatteryEnergyWh - session.measuredEnergyWh) > 0.01 {
|
|
| 487 |
- MeterInfoRowView(label: "Effective Battery Energy", value: "\(effectiveBatteryEnergyWh.format(decimalDigits: 3)) Wh") |
|
| 488 |
- } |
|
| 489 |
- if let chargerID = session.chargerID, |
|
| 490 |
- let charger = appData.chargedDeviceSummary(id: chargerID) {
|
|
| 491 |
- MeterInfoRowView(label: chargedDevice.hasMultipleChargingTransports ? "Wireless Charger" : "Charger", value: charger.name) |
|
| 492 |
- } |
|
| 493 |
- if let wirelessSessionHint = wirelessSessionHint(for: session) {
|
|
| 494 |
- Text(wirelessSessionHint) |
|
| 495 |
- .font(.caption2) |
|
| 496 |
- .foregroundColor(session.shouldWarnAboutLowWirelessEfficiency ? .orange : .secondary) |
|
| 497 |
- } |
|
| 498 |
- if let sessionWarning = sessionWarning(for: session) {
|
|
| 499 |
- Label(sessionWarning, systemImage: "exclamationmark.triangle") |
|
| 500 |
- .font(.caption2) |
|
| 501 |
- .foregroundColor(.orange) |
|
| 479 |
+ return VStack(alignment: .leading, spacing: 0) {
|
|
| 480 |
+ |
|
| 481 |
+ // Header — always visible, tappable |
|
| 482 |
+ HStack(spacing: 8) {
|
|
| 483 |
+ Text("Battery")
|
|
| 484 |
+ .font(.headline) |
|
| 485 |
+ Spacer(minLength: 0) |
|
| 486 |
+ Image(systemName: "chevron.up") |
|
| 487 |
+ .font(.caption.weight(.semibold)) |
|
| 488 |
+ .foregroundColor(.secondary) |
|
| 489 |
+ .rotationEffect(.degrees(isBatteryCardExpanded ? 0 : -180)) |
|
| 490 |
+ .animation(.easeInOut(duration: 0.2), value: isBatteryCardExpanded) |
|
| 491 |
+ } |
|
| 492 |
+ .contentShape(Rectangle()) |
|
| 493 |
+ .onTapGesture {
|
|
| 494 |
+ withAnimation(.easeInOut(duration: 0.25)) {
|
|
| 495 |
+ isBatteryCardExpanded.toggle() |
|
| 502 | 496 |
} |
| 497 |
+ } |
|
| 503 | 498 |
|
| 504 |
- // Battery percentages |
|
| 505 |
- if session.startBatteryPercent != nil || session.endBatteryPercent != nil {
|
|
| 506 |
- Divider() |
|
| 499 |
+ // Preview bar — always visible when there is enough data |
|
| 500 |
+ if showsPreview, let start = startPercent, let end = endPercent {
|
|
| 501 |
+ batteryPreviewBar( |
|
| 502 |
+ startPercent: start, |
|
| 503 |
+ endPercent: end, |
|
| 504 |
+ checkpoints: session.checkpoints, |
|
| 505 |
+ isEstimatedEnd: isEstimatedEnd |
|
| 506 |
+ ) |
|
| 507 |
+ .padding(.top, 10) |
|
| 508 |
+ } |
|
| 509 |
+ |
|
| 510 |
+ // Collapsible detail |
|
| 511 |
+ if isBatteryCardExpanded {
|
|
| 512 |
+ VStack(alignment: .leading, spacing: 10) {
|
|
| 513 |
+ |
|
| 514 |
+ // Energy |
|
| 507 | 515 |
HStack(alignment: .top, spacing: 12) {
|
| 508 |
- if let v = session.startBatteryPercent {
|
|
| 509 |
- overviewStatCell(label: "Start Battery", value: "\(v.format(decimalDigits: 0))%") |
|
| 510 |
- } |
|
| 511 |
- if let v = session.endBatteryPercent {
|
|
| 512 |
- overviewStatCell(label: "End Battery", value: "\(v.format(decimalDigits: 0))%") |
|
| 516 |
+ overviewStatCell(label: "Battery Energy", value: "\(displayedEnergyWh.format(decimalDigits: 3)) Wh") |
|
| 517 |
+ if let capacityEstimateWh = session.capacityEstimateWh {
|
|
| 518 |
+ overviewStatCell(label: "Capacity Estimate", value: "\(capacityEstimateWh.format(decimalDigits: 2)) Wh") |
|
| 513 | 519 |
} |
| 514 | 520 |
} |
| 515 |
- if session.batteryDeltaPercent != nil || session.targetBatteryPercent != nil {
|
|
| 521 |
+ if abs(displayedEnergyWh - session.measuredEnergyWh) > 0.01 {
|
|
| 522 |
+ MeterInfoRowView(label: "Measured Energy", value: "\(session.measuredEnergyWh.format(decimalDigits: 3)) Wh") |
|
| 523 |
+ } |
|
| 524 |
+ if let effectiveBatteryEnergyWh = session.effectiveBatteryEnergyWh, |
|
| 525 |
+ abs(effectiveBatteryEnergyWh - session.measuredEnergyWh) > 0.01 {
|
|
| 526 |
+ MeterInfoRowView(label: "Effective Battery Energy", value: "\(effectiveBatteryEnergyWh.format(decimalDigits: 3)) Wh") |
|
| 527 |
+ } |
|
| 528 |
+ if let chargerID = session.chargerID, |
|
| 529 |
+ let charger = appData.chargedDeviceSummary(id: chargerID) {
|
|
| 530 |
+ MeterInfoRowView(label: chargedDevice.hasMultipleChargingTransports ? "Wireless Charger" : "Charger", value: charger.name) |
|
| 531 |
+ } |
|
| 532 |
+ if let wirelessSessionHint = wirelessSessionHint(for: session) {
|
|
| 533 |
+ Text(wirelessSessionHint) |
|
| 534 |
+ .font(.caption2) |
|
| 535 |
+ .foregroundColor(session.shouldWarnAboutLowWirelessEfficiency ? .orange : .secondary) |
|
| 536 |
+ } |
|
| 537 |
+ if let sessionWarning = sessionWarning(for: session) {
|
|
| 538 |
+ Label(sessionWarning, systemImage: "exclamationmark.triangle") |
|
| 539 |
+ .font(.caption2) |
|
| 540 |
+ .foregroundColor(.orange) |
|
| 541 |
+ } |
|
| 542 |
+ |
|
| 543 |
+ // Battery percentages |
|
| 544 |
+ if startPercent != nil || session.endBatteryPercent != nil {
|
|
| 545 |
+ Divider() |
|
| 516 | 546 |
HStack(alignment: .top, spacing: 12) {
|
| 517 |
- if let v = session.batteryDeltaPercent {
|
|
| 518 |
- overviewStatCell(label: "Battery Delta", value: "\(v.format(decimalDigits: 0))%") |
|
| 547 |
+ if let v = startPercent {
|
|
| 548 |
+ overviewStatCell(label: "Start Battery", value: "\(v.format(decimalDigits: 0))%") |
|
| 519 | 549 |
} |
| 520 |
- if let v = session.targetBatteryPercent {
|
|
| 521 |
- overviewStatCell(label: "Target", value: "\(v.format(decimalDigits: 0))%") |
|
| 550 |
+ if let v = session.endBatteryPercent {
|
|
| 551 |
+ overviewStatCell(label: "End Battery", value: "\(v.format(decimalDigits: 0))%") |
|
| 522 | 552 |
} |
| 523 | 553 |
} |
| 524 |
- } |
|
| 525 |
- if let batteryPrediction {
|
|
| 526 |
- HStack(alignment: .top, spacing: 12) {
|
|
| 527 |
- overviewStatCell(label: "Predicted Battery", value: "\(batteryPrediction.predictedPercent.format(decimalDigits: 0))%") |
|
| 554 |
+ if session.batteryDeltaPercent != nil || session.targetBatteryPercent != nil {
|
|
| 555 |
+ HStack(alignment: .top, spacing: 12) {
|
|
| 556 |
+ if let v = session.batteryDeltaPercent {
|
|
| 557 |
+ overviewStatCell(label: "Battery Delta", value: "\(v.format(decimalDigits: 0))%") |
|
| 558 |
+ } |
|
| 559 |
+ if let v = session.targetBatteryPercent {
|
|
| 560 |
+ overviewStatCell(label: "Target", value: "\(v.format(decimalDigits: 0))%") |
|
| 561 |
+ } |
|
| 562 |
+ } |
|
| 563 |
+ } |
|
| 564 |
+ if let batteryPrediction {
|
|
| 565 |
+ HStack(alignment: .top, spacing: 12) {
|
|
| 566 |
+ overviewStatCell(label: "Predicted Battery", value: "\(batteryPrediction.predictedPercent.format(decimalDigits: 0))%") |
|
| 567 |
+ } |
|
| 568 |
+ Text( |
|
| 569 |
+ "Anchored to \(batteryPrediction.anchorDescription) at \(batteryPrediction.anchorPercent.format(decimalDigits: 0))% using \(batteryPrediction.estimatedCapacityWh.format(decimalDigits: 2)) Wh estimated capacity." |
|
| 570 |
+ ) |
|
| 571 |
+ .font(.caption2) |
|
| 572 |
+ .foregroundColor(.secondary) |
|
| 528 | 573 |
} |
| 529 |
- Text( |
|
| 530 |
- "Anchored to \(batteryPrediction.anchorDescription) at \(batteryPrediction.anchorPercent.format(decimalDigits: 0))% using \(batteryPrediction.estimatedCapacityWh.format(decimalDigits: 2)) Wh estimated capacity." |
|
| 531 |
- ) |
|
| 532 |
- .font(.caption2) |
|
| 533 |
- .foregroundColor(.secondary) |
|
| 534 | 574 |
} |
| 575 |
+ |
|
| 576 |
+ // Checkpoints |
|
| 577 |
+ Divider() |
|
| 578 |
+ BatteryCheckpointSectionView( |
|
| 579 |
+ sessionID: session.id, |
|
| 580 |
+ checkpoints: session.checkpoints, |
|
| 581 |
+ message: session.status.isOpen |
|
| 582 |
+ ? "The checkpoint is stored on the active charge session and is used for capacity estimation and charge-level prediction." |
|
| 583 |
+ : "These checkpoints were saved with this closed session and feed capacity estimation and charge-level prediction.", |
|
| 584 |
+ canAddCheckpoint: hasMonitoringControls && appData.canAddBatteryCheckpoint(to: session.id), |
|
| 585 |
+ canDeleteCheckpoint: hasMonitoringControls || session.status.isOpen == false, |
|
| 586 |
+ requirementMessage: session.status.isOpen ? appData.batteryCheckpointCaptureRequirementMessage(for: session.id) : nil, |
|
| 587 |
+ effectiveEnergyWhOverride: hasMonitoringControls ? displayedEnergyWh : nil, |
|
| 588 |
+ onDelete: { checkpoint in
|
|
| 589 |
+ pendingCheckpointDeletion = checkpoint |
|
| 590 |
+ } |
|
| 591 |
+ ) |
|
| 535 | 592 |
} |
| 593 |
+ .padding(.top, 12) |
|
| 594 |
+ .transition(.opacity.combined(with: .move(edge: .top))) |
|
| 595 |
+ } |
|
| 596 |
+ } |
|
| 597 |
+ .frame(maxWidth: .infinity, alignment: .leading) |
|
| 598 |
+ .padding(18) |
|
| 599 |
+ .meterCard(tint: .orange, fillOpacity: 0.18, strokeOpacity: 0.24) |
|
| 600 |
+ } |
|
| 536 | 601 |
|
| 537 |
- // Checkpoints |
|
| 538 |
- Divider() |
|
| 539 |
- BatteryCheckpointSectionView( |
|
| 540 |
- sessionID: session.id, |
|
| 541 |
- checkpoints: session.checkpoints, |
|
| 542 |
- message: session.status.isOpen |
|
| 543 |
- ? "The checkpoint is stored on the active charge session and is used for capacity estimation and charge-level prediction." |
|
| 544 |
- : "These checkpoints were saved with this closed session and feed capacity estimation and charge-level prediction.", |
|
| 545 |
- canAddCheckpoint: hasMonitoringControls && appData.canAddBatteryCheckpoint(to: session.id), |
|
| 546 |
- canDeleteCheckpoint: hasMonitoringControls || session.status.isOpen == false, |
|
| 547 |
- requirementMessage: session.status.isOpen ? appData.batteryCheckpointCaptureRequirementMessage(for: session.id) : nil, |
|
| 548 |
- effectiveEnergyWhOverride: hasMonitoringControls ? displayedEnergyWh : nil, |
|
| 549 |
- onDelete: { checkpoint in
|
|
| 550 |
- pendingCheckpointDeletion = checkpoint |
|
| 602 |
+ private func batteryPreviewBar( |
|
| 603 |
+ startPercent: Double, |
|
| 604 |
+ endPercent: Double, |
|
| 605 |
+ checkpoints: [ChargeCheckpointSummary], |
|
| 606 |
+ isEstimatedEnd: Bool |
|
| 607 |
+ ) -> some View {
|
|
| 608 |
+ let startFrac = CGFloat(max(0, min(startPercent, 100)) / 100) |
|
| 609 |
+ let endFrac = CGFloat(max(0, min(endPercent, 100)) / 100) |
|
| 610 |
+ let color = batteryColor(for: endPercent) |
|
| 611 |
+ |
|
| 612 |
+ return HStack(spacing: 6) {
|
|
| 613 |
+ Text("\(Int(startPercent.rounded()))%")
|
|
| 614 |
+ .font(.caption2.weight(.semibold)) |
|
| 615 |
+ .foregroundColor(.secondary) |
|
| 616 |
+ .monospacedDigit() |
|
| 617 |
+ .frame(minWidth: 26, alignment: .trailing) |
|
| 618 |
+ |
|
| 619 |
+ GeometryReader { geo in
|
|
| 620 |
+ let w = geo.size.width |
|
| 621 |
+ ZStack(alignment: .leading) {
|
|
| 622 |
+ Capsule() |
|
| 623 |
+ .fill(Color.primary.opacity(0.10)) |
|
| 624 |
+ |
|
| 625 |
+ Rectangle() |
|
| 626 |
+ .fill(color.opacity(isEstimatedEnd ? 0.45 : 0.72)) |
|
| 627 |
+ .frame(width: max(w * (endFrac - startFrac), 3)) |
|
| 628 |
+ .offset(x: w * startFrac) |
|
| 629 |
+ |
|
| 630 |
+ ForEach(checkpoints, id: \.id) { cp in
|
|
| 631 |
+ let xFrac = CGFloat(max(0, min(cp.batteryPercent, 100)) / 100) |
|
| 632 |
+ let isFinal = cp.flag == .final |
|
| 633 |
+ Rectangle() |
|
| 634 |
+ .fill(Color.white.opacity(isFinal ? 0.95 : 0.70)) |
|
| 635 |
+ .frame(width: isFinal ? 2.0 : 1.5, height: 10) |
|
| 636 |
+ .offset(x: w * xFrac - (isFinal ? 1.0 : 0.75)) |
|
| 551 | 637 |
} |
| 552 |
- ) |
|
| 638 |
+ } |
|
| 639 |
+ .clipShape(Capsule()) |
|
| 640 |
+ } |
|
| 641 |
+ .frame(height: 8) |
|
| 642 |
+ |
|
| 643 |
+ HStack(spacing: 1) {
|
|
| 644 |
+ if isEstimatedEnd {
|
|
| 645 |
+ Text("~")
|
|
| 646 |
+ .font(.caption2) |
|
| 647 |
+ .foregroundColor(.secondary) |
|
| 648 |
+ } |
|
| 649 |
+ Text("\(Int(endPercent.rounded()))%")
|
|
| 650 |
+ .font(.caption2.weight(.semibold)) |
|
| 651 |
+ .foregroundColor(isEstimatedEnd ? .secondary : color) |
|
| 652 |
+ .monospacedDigit() |
|
| 553 | 653 |
} |
| 654 |
+ .frame(minWidth: 32, alignment: .leading) |
|
| 554 | 655 |
} |
| 555 | 656 |
} |
| 556 | 657 |
|