Showing 1 changed files with 273 additions and 56 deletions
+273 -56
USB Meter/Views/ChargedDevices/Details/ChargedDeviceDetailView.swift
@@ -22,6 +22,9 @@ struct ChargedDeviceDetailView: View {
22 22
     @State private var editorVisibility = false
23 23
     @State private var deleteConfirmationVisibility = false
24 24
     @State private var selectedTab: DetailTab = .overview
25
+    @State private var sessionSelectMode = false
26
+    @State private var selectedSessionIDs: Set<UUID> = []
27
+    @State private var pendingBatchDeletion = false
25 28
 
26 29
     let chargedDeviceID: UUID
27 30
 
@@ -30,14 +33,6 @@ struct ChargedDeviceDetailView: View {
30 33
             if let chargedDevice = appData.chargedDeviceSummary(id: chargedDeviceID) {
31 34
                 tabbedDetailView(chargedDevice)
32 35
                 .navigationTitle(chargedDevice.name)
33
-                .toolbar {
34
-                    ToolbarItemGroup(placement: .primaryAction) {
35
-                        Button("Edit", action: showEditor)
36
-                        Button(role: .destructive, action: showDeleteConfirmation) {
37
-                            Image(systemName: "trash")
38
-                        }
39
-                    }
40
-                }
41 36
             } else {
42 37
                 Text("This device is no longer available.")
43 38
                     .foregroundColor(.secondary)
@@ -68,6 +63,16 @@ struct ChargedDeviceDetailView: View {
68 63
         } message: {
69 64
             Text(deletionMessage)
70 65
         }
66
+        .confirmationDialog(
67
+            "Delete \(selectedSessionIDs.count) Session\(selectedSessionIDs.count == 1 ? "" : "s")?",
68
+            isPresented: $pendingBatchDeletion,
69
+            titleVisibility: .visible
70
+        ) {
71
+            Button("Delete", role: .destructive, action: deleteSelectedSessions)
72
+            Button("Cancel", role: .cancel) {}
73
+        } message: {
74
+            Text("Deleting these sessions also recalculates capacity and every derived metric that used them.")
75
+        }
71 76
     }
72 77
 
73 78
     private func tabbedDetailView(_ chargedDevice: ChargedDeviceSummary) -> some View {
@@ -86,9 +91,15 @@ struct ChargedDeviceDetailView: View {
86 91
                     systemImage: systemImage(for:)
87 92
                 )
88 93
 
89
-                ScrollView {
90
-                    tabContent(displayedTab, chargedDevice: chargedDevice)
91
-                        .padding()
94
+                Group {
95
+                    if displayedTab == .sessions {
96
+                        sessionsTabLayout(chargedDevice)
97
+                    } else {
98
+                        ScrollView {
99
+                            tabContent(displayedTab, chargedDevice: chargedDevice)
100
+                                .padding()
101
+                        }
102
+                    }
92 103
                 }
93 104
                 .id(displayedTab)
94 105
                 .transition(.opacity.combined(with: .move(edge: .trailing)))
@@ -96,6 +107,10 @@ struct ChargedDeviceDetailView: View {
96 107
             }
97 108
             .animation(.easeInOut(duration: 0.22), value: displayedTab)
98 109
             .animation(.easeInOut(duration: 0.22), value: tabs)
110
+            .onChange(of: selectedTab) { _ in
111
+                sessionSelectMode = false
112
+                selectedSessionIDs.removeAll()
113
+            }
99 114
         }
100 115
         .background(detailBackground(for: chargedDevice))
101 116
         .onAppear {
@@ -181,6 +196,128 @@ struct ChargedDeviceDetailView: View {
181 196
         settingsCard(chargedDevice)
182 197
     }
183 198
 
199
+    @ViewBuilder
200
+    private func sessionsTabLayout(_ chargedDevice: ChargedDeviceSummary) -> some View {
201
+        let allSessions = chargedDevice.sessions.sorted { lhs, rhs in
202
+            let lOpen = lhs.status.isOpen, rOpen = rhs.status.isOpen
203
+            if lOpen != rOpen { return lOpen }
204
+            return lhs.startedAt > rhs.startedAt
205
+        }
206
+        let totalEnergyWh = allSessions.reduce(0.0) { $0 + $1.effectiveOrMeasuredEnergyWh }
207
+        let totalDuration  = allSessions.reduce(0.0) { $0 + max($1.effectiveDuration, 0) }
208
+
209
+        VStack(spacing: 0) {
210
+            // Fixed non-scrolling header
211
+            VStack(spacing: 10) {
212
+                sessionsSummaryStrip(
213
+                    count: allSessions.count,
214
+                    totalEnergyWh: totalEnergyWh,
215
+                    totalDuration: totalDuration,
216
+                    hasActive: chargedDevice.activeSession != nil
217
+                )
218
+
219
+                if !allSessions.isEmpty {
220
+                    HStack(spacing: 12) {
221
+                        if sessionSelectMode && !selectedSessionIDs.isEmpty {
222
+                            Text("\(selectedSessionIDs.count) selected")
223
+                                .font(.subheadline)
224
+                                .foregroundColor(.secondary)
225
+                                .transition(.opacity.combined(with: .move(edge: .leading)))
226
+                        }
227
+                        Spacer()
228
+                        if sessionSelectMode && !selectedSessionIDs.isEmpty {
229
+                            Button {
230
+                                pendingBatchDeletion = true
231
+                            } label: {
232
+                                Image(systemName: "trash").foregroundColor(.red)
233
+                            }
234
+                            .transition(.opacity.combined(with: .scale))
235
+                        }
236
+                        Button(sessionSelectMode ? "Cancel" : "Select") {
237
+                            withAnimation(.easeInOut(duration: 0.2)) {
238
+                                sessionSelectMode.toggle()
239
+                                if !sessionSelectMode { selectedSessionIDs.removeAll() }
240
+                            }
241
+                        }
242
+                    }
243
+                    .animation(.easeInOut(duration: 0.2), value: sessionSelectMode)
244
+                    .animation(.easeInOut(duration: 0.2), value: selectedSessionIDs.isEmpty)
245
+                }
246
+            }
247
+            .padding()
248
+
249
+            // Scrollable session list
250
+            if allSessions.isEmpty {
251
+                emptyStateCard(
252
+                    title: "No Sessions",
253
+                    message: "Charging sessions will appear here after this device is used in a recording.",
254
+                    tint: .teal
255
+                )
256
+                .padding([.horizontal, .bottom])
257
+            } else {
258
+                ScrollView {
259
+                    VStack(spacing: 10) {
260
+                        ForEach(allSessions, id: \.id) { session in
261
+                            sessionListItem(session, chargedDevice: chargedDevice)
262
+                        }
263
+                    }
264
+                    .padding([.horizontal, .bottom])
265
+                }
266
+            }
267
+        }
268
+    }
269
+
270
+    private func sessionsSummaryStrip(
271
+        count: Int,
272
+        totalEnergyWh: Double,
273
+        totalDuration: TimeInterval,
274
+        hasActive: Bool
275
+    ) -> some View {
276
+        HStack(spacing: 0) {
277
+            summaryCell(value: "\(count)", label: count == 1 ? "session" : "sessions")
278
+            Divider().frame(height: 30)
279
+            summaryCell(value: "\(totalEnergyWh.format(decimalDigits: 2)) Wh", label: "energy")
280
+            Divider().frame(height: 30)
281
+            summaryCell(value: formatAccumulatedDuration(totalDuration), label: "duration")
282
+            if hasActive {
283
+                Divider().frame(height: 30)
284
+                HStack(spacing: 4) {
285
+                    Circle().fill(Color.green).frame(width: 6, height: 6)
286
+                    Text("Live")
287
+                        .font(.caption2.weight(.semibold))
288
+                        .foregroundColor(.green)
289
+                }
290
+                .frame(maxWidth: .infinity)
291
+            }
292
+        }
293
+        .padding(.vertical, 8)
294
+        .padding(.horizontal, 12)
295
+        .meterCard(tint: .teal, fillOpacity: 0.08, strokeOpacity: 0.14, cornerRadius: 14)
296
+    }
297
+
298
+    private func summaryCell(value: String, label: String) -> some View {
299
+        VStack(spacing: 2) {
300
+            Text(value)
301
+                .font(.subheadline.weight(.bold))
302
+                .foregroundColor(.primary)
303
+                .monospacedDigit()
304
+                .lineLimit(1)
305
+                .minimumScaleFactor(0.7)
306
+            Text(label)
307
+                .font(.caption2)
308
+                .foregroundColor(.secondary)
309
+        }
310
+        .frame(maxWidth: .infinity)
311
+    }
312
+
313
+    private func formatAccumulatedDuration(_ duration: TimeInterval) -> String {
314
+        let formatter = DateComponentsFormatter()
315
+        formatter.allowedUnits = duration >= 3600 ? [.hour, .minute] : [.minute, .second]
316
+        formatter.unitsStyle = .abbreviated
317
+        formatter.zeroFormattingBehavior = .dropAll
318
+        return formatter.string(from: duration) ?? "0m"
319
+    }
320
+
184 321
     private func headerCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
185 322
         HStack(alignment: .top, spacing: 18) {
186 323
             ChargedDeviceQRCodeView(qrIdentifier: chargedDevice.qrIdentifier, side: 118)
@@ -622,6 +759,7 @@ struct ChargedDeviceDetailView: View {
622 759
     ) -> some View {
623 760
         let totalEnergyWh = sessions.reduce(0) { $0 + $1.effectiveOrMeasuredEnergyWh }
624 761
         let completedCount = sessions.filter { $0.status == .completed }.count
762
+        let sortedSessions = sessions.sorted { $0.startedAt > $1.startedAt }
625 763
 
626 764
         return VStack(alignment: .leading, spacing: 14) {
627 765
             MeterInfoCardView(title: "Closed Sessions", tint: .teal) {
@@ -630,8 +768,35 @@ struct ChargedDeviceDetailView: View {
630 768
                 MeterInfoRowView(label: "Total Energy", value: "\(totalEnergyWh.format(decimalDigits: 2)) Wh")
631 769
             }
632 770
 
771
+            HStack(spacing: 12) {
772
+                if sessionSelectMode && !selectedSessionIDs.isEmpty {
773
+                    Text("\(selectedSessionIDs.count) selected")
774
+                        .font(.subheadline)
775
+                        .foregroundColor(.secondary)
776
+                        .transition(.opacity.combined(with: .move(edge: .leading)))
777
+                }
778
+                Spacer()
779
+                if sessionSelectMode && !selectedSessionIDs.isEmpty {
780
+                    Button {
781
+                        pendingBatchDeletion = true
782
+                    } label: {
783
+                        Image(systemName: "trash")
784
+                            .foregroundColor(.red)
785
+                    }
786
+                    .transition(.opacity.combined(with: .scale))
787
+                }
788
+                Button(sessionSelectMode ? "Cancel" : "Select") {
789
+                    withAnimation(.easeInOut(duration: 0.2)) {
790
+                        sessionSelectMode.toggle()
791
+                        if !sessionSelectMode { selectedSessionIDs.removeAll() }
792
+                    }
793
+                }
794
+            }
795
+            .animation(.easeInOut(duration: 0.2), value: sessionSelectMode)
796
+            .animation(.easeInOut(duration: 0.2), value: selectedSessionIDs.isEmpty)
797
+
633 798
             VStack(spacing: 10) {
634
-                ForEach(sessions.sorted { $0.startedAt > $1.startedAt }, id: \.id) { session in
799
+                ForEach(sortedSessions, id: \.id) { session in
635 800
                     sessionListItem(session, chargedDevice: chargedDevice)
636 801
                 }
637 802
             }
@@ -643,63 +808,107 @@ struct ChargedDeviceDetailView: View {
643 808
         chargedDevice: ChargedDeviceSummary
644 809
     ) -> some View {
645 810
         let sessionTint = statusTint(for: session)
811
+        let isOpen = session.status.isOpen
812
+        let isSelected = selectedSessionIDs.contains(session.id)
813
+
814
+        return Group {
815
+            if sessionSelectMode && !isOpen {
816
+                Button {
817
+                    withAnimation(.easeInOut(duration: 0.15)) {
818
+                        if isSelected { selectedSessionIDs.remove(session.id) }
819
+                        else          { selectedSessionIDs.insert(session.id) }
820
+                    }
821
+                } label: {
822
+                    sessionRowContent(session, sessionTint: sessionTint, isOpen: isOpen, isSelected: isSelected)
823
+                }
824
+                .buttonStyle(.plain)
825
+            } else {
826
+                NavigationLink(
827
+                    destination: ChargeSessionDetailView(
828
+                        chargedDeviceID: chargedDevice.id,
829
+                        sessionID: session.id
830
+                    )
831
+                ) {
832
+                    sessionRowContent(session, sessionTint: sessionTint, isOpen: isOpen, isSelected: false)
833
+                }
834
+                .buttonStyle(.plain)
835
+            }
836
+        }
837
+    }
646 838
 
647
-        return NavigationLink(
648
-            destination: ChargeSessionDetailView(
649
-                chargedDeviceID: chargedDevice.id,
650
-                sessionID: session.id
651
-            )
652
-        ) {
653
-            VStack(alignment: .leading, spacing: 10) {
654
-                HStack(alignment: .firstTextBaseline, spacing: 10) {
655
-                    VStack(alignment: .leading, spacing: 2) {
656
-                        Text(session.startedAt.format())
657
-                            .font(.subheadline.weight(.semibold))
658
-                        Text(session.status.title)
659
-                            .font(.caption2)
660
-                            .foregroundColor(sessionTint)
839
+    private func sessionRowContent(
840
+        _ session: ChargeSessionSummary,
841
+        sessionTint: Color,
842
+        isOpen: Bool,
843
+        isSelected: Bool
844
+    ) -> some View {
845
+        VStack(alignment: .leading, spacing: 10) {
846
+            HStack(alignment: .firstTextBaseline, spacing: 10) {
847
+                if sessionSelectMode {
848
+                    Group {
849
+                        if isOpen {
850
+                            Image(systemName: "minus.circle")
851
+                                .foregroundColor(.secondary.opacity(0.35))
852
+                        } else {
853
+                            Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
854
+                                .foregroundColor(isSelected ? .teal : .secondary)
855
+                        }
661 856
                     }
857
+                    .font(.body)
858
+                    .transition(.opacity)
859
+                }
662 860
 
663
-                    Spacer()
861
+                VStack(alignment: .leading, spacing: 2) {
862
+                    Text(session.startedAt.format())
863
+                        .font(.subheadline.weight(.semibold))
864
+                    Text(session.status.title)
865
+                        .font(.caption2)
866
+                        .foregroundColor(sessionTint)
867
+                }
664 868
 
665
-                    VStack(alignment: .trailing, spacing: 2) {
666
-                        Text("\(session.effectiveOrMeasuredEnergyWh.format(decimalDigits: 2)) Wh")
667
-                            .font(.subheadline.weight(.semibold))
668
-                            .foregroundColor(.primary)
669
-                        Text(sessionDurationText(session))
670
-                            .font(.caption)
671
-                            .foregroundColor(.secondary)
672
-                    }
869
+                Spacer()
870
+
871
+                VStack(alignment: .trailing, spacing: 2) {
872
+                    Text("\(session.effectiveOrMeasuredEnergyWh.format(decimalDigits: 2)) Wh")
873
+                        .font(.subheadline.weight(.semibold))
874
+                        .foregroundColor(.primary)
875
+                    Text(sessionDurationText(session))
876
+                        .font(.caption)
877
+                        .foregroundColor(.secondary)
673 878
                 }
879
+            }
674 880
 
675
-                Divider()
881
+            Divider()
676 882
 
677
-                HStack(spacing: 8) {
678
-                    if let batteryDelta = session.batteryDeltaPercent {
679
-                        Label("\(batteryDelta >= 0 ? "+" : "")\(Int(batteryDelta.rounded()))% charged", systemImage: "battery.100percent")
680
-                            .font(.caption2)
681
-                            .foregroundColor(.secondary)
682
-                    }
883
+            HStack(spacing: 8) {
884
+                if let batteryDelta = session.batteryDeltaPercent {
885
+                    Label("\(batteryDelta >= 0 ? "+" : "")\(Int(batteryDelta.rounded()))% charged", systemImage: "battery.100percent")
886
+                        .font(.caption2)
887
+                        .foregroundColor(.secondary)
888
+                }
683 889
 
684
-                    if let capacityWh = session.capacityEstimateWh {
685
-                        Text("est. \(capacityWh.format(decimalDigits: 1)) Wh")
686
-                            .font(.caption2)
687
-                            .foregroundColor(.secondary)
688
-                    }
890
+                if let capacityWh = session.capacityEstimateWh {
891
+                    Text("est. \(capacityWh.format(decimalDigits: 1)) Wh")
892
+                        .font(.caption2)
893
+                        .foregroundColor(.secondary)
894
+                }
689 895
 
690
-                    Spacer()
896
+                Spacer()
691 897
 
692
-                    if !session.displayedAggregatedSamples.isEmpty {
693
-                        Label("\(session.displayedAggregatedSamples.count) points", systemImage: "chart.xyaxis.line")
694
-                            .font(.caption2)
695
-                            .foregroundColor(.secondary)
696
-                    }
898
+                if !session.displayedAggregatedSamples.isEmpty {
899
+                    Label("\(session.displayedAggregatedSamples.count) points", systemImage: "chart.xyaxis.line")
900
+                        .font(.caption2)
901
+                        .foregroundColor(.secondary)
697 902
                 }
698 903
             }
699
-            .padding(12)
700
-            .meterCard(tint: sessionTint, fillOpacity: 0.08, strokeOpacity: 0.14, cornerRadius: 14)
701 904
         }
702
-        .buttonStyle(.plain)
905
+        .padding(12)
906
+        .meterCard(
907
+            tint: sessionTint,
908
+            fillOpacity: isSelected ? 0.16 : (isOpen ? 0.14 : 0.08),
909
+            strokeOpacity: isSelected ? 0.22 : (isOpen ? 0.30 : 0.14),
910
+            cornerRadius: 14
911
+        )
703 912
     }
704 913
 
705 914
     private func closedSessions(for chargedDevice: ChargedDeviceSummary) -> [ChargeSessionSummary] {
@@ -863,6 +1072,14 @@ struct ChargedDeviceDetailView: View {
863 1072
         }
864 1073
     }
865 1074
 
1075
+    private func deleteSelectedSessions() {
1076
+        for id in selectedSessionIDs {
1077
+            _ = appData.deleteChargeSession(sessionID: id)
1078
+        }
1079
+        selectedSessionIDs.removeAll()
1080
+        sessionSelectMode = false
1081
+    }
1082
+
866 1083
     private func showEditor() {
867 1084
         editorVisibility = true
868 1085
     }