Showing 1 changed files with 167 additions and 66 deletions
+167 -66
USB Meter/Views/ChargedDevices/Sessions/ChargeSessionDetailView.swift
@@ -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