Showing 4 changed files with 216 additions and 71 deletions
+78 -17
HealthProbe/Services/HealthKitService.swift
@@ -23,6 +23,18 @@ struct MonitoredType: Identifiable, @unchecked Sendable {
23 23
     let objectType: HKObjectType?   // nil = unsupported on this OS/device
24 24
 }
25 25
 
26
+extension Array where Element == MonitoredType {
27
+    func sortedByFetchDisplayNameDescending() -> [MonitoredType] {
28
+        sorted {
29
+            let displayNameOrder = $0.displayName.localizedCaseInsensitiveCompare($1.displayName)
30
+            if displayNameOrder == .orderedSame {
31
+                return $0.id > $1.id
32
+            }
33
+            return displayNameOrder == .orderedDescending
34
+        }
35
+    }
36
+}
37
+
26 38
 final class HealthKitService {
27 39
     static let shared = HealthKitService()
28 40
     let store = HKHealthStore()
@@ -66,7 +78,9 @@ final class HealthKitService {
66 78
         timeoutMultiplier: Double = 1,
67 79
         progress: SnapshotFetchProgress? = nil
68 80
     ) async throws -> HealthSnapshot {
69
-        let active = Self.allTypes.filter { selectedTypeIDs.contains($0.id) }
81
+        let active = Self.allTypes
82
+            .filter { selectedTypeIDs.contains($0.id) }
83
+            .sortedByFetchDisplayNameDescending()
70 84
         let deviceResolution = KeychainService.resolveDeviceID(swiftDataStoreIsEmpty: isStoreEmpty(context: context))
71 85
 
72 86
         let snapshot = HealthSnapshot(
@@ -79,8 +93,6 @@ final class HealthKitService {
79 93
         snapshot.triggerReason = triggerReason
80 94
         snapshot.retryOfSnapshotID = retryOfSnapshotID
81 95
         snapshot.yearlyCountTimezoneIdentifier = TimeZone.current.identifier
82
-        context.insert(snapshot)
83
-
84 96
         // Fetch raw HealthKit data off the main actor, then assemble SwiftData models here
85 97
         // on the main actor to prevent data races on managed object context.
86 98
         let fetchResults = await fetchAllTypeCounts(
@@ -92,13 +104,10 @@ final class HealthKitService {
92 104
         )
93 105
         let typeCounts: [TypeCount] = fetchResults.map { result in
94 106
             let tc = result.makeTypeCount()
95
-            context.insert(tc)
96
-            for yearlyCount in tc.yearlyCounts ?? [] {
97
-                context.insert(yearlyCount)
98
-            }
99 107
             tc.snapshot = snapshot
100 108
             return tc
101 109
         }
110
+        snapshot.typeCounts = typeCounts
102 111
 
103 112
         // Invariant assertions before save — debug asserts + release silent correction
104 113
         for tc in typeCounts {
@@ -119,6 +128,66 @@ final class HealthKitService {
119 128
 
120 129
         snapshot.snapshotQuality = deriveSnapshotQuality(from: typeCounts)
121 130
 
131
+        configureSnapshotMetadata(
132
+            snapshot,
133
+            typeCounts: typeCounts,
134
+            intendedTypeIDs: active.map { $0.id },
135
+            context: context
136
+        )
137
+
138
+        if snapshot.snapshotQuality == .complete {
139
+            try await persistSnapshot(snapshot, typeCounts: typeCounts, context: context)
140
+        }
141
+
142
+        return snapshot
143
+    }
144
+
145
+    @MainActor
146
+    func savePartialSnapshot(_ snapshot: HealthSnapshot, in context: ModelContext) async throws -> HealthSnapshot {
147
+        let typeCounts = snapshot.typeCounts ?? []
148
+        guard snapshot.snapshotQuality != .complete else {
149
+            return snapshot
150
+        }
151
+
152
+        configureSnapshotMetadata(
153
+            snapshot,
154
+            typeCounts: typeCounts,
155
+            intendedTypeIDs: typeCounts.map(\.typeIdentifier),
156
+            context: context
157
+        )
158
+        try await persistSnapshot(snapshot, typeCounts: typeCounts, context: context)
159
+        return snapshot
160
+    }
161
+
162
+    // MARK: - Snapshot persistence
163
+
164
+    private func persistSnapshot(
165
+        _ snapshot: HealthSnapshot,
166
+        typeCounts: [TypeCount],
167
+        context: ModelContext
168
+    ) async throws {
169
+        context.insert(snapshot)
170
+        for typeCount in typeCounts {
171
+            context.insert(typeCount)
172
+            for yearlyCount in typeCount.yearlyCounts ?? [] {
173
+                context.insert(yearlyCount)
174
+            }
175
+            typeCount.snapshot = snapshot
176
+        }
177
+        snapshot.typeCounts = typeCounts
178
+
179
+        try context.save()
180
+
181
+        // Post-save pipeline: delta computation + anomaly detection
182
+        try await runPostSavePipeline(snapshot: snapshot, typeCounts: typeCounts, context: context)
183
+    }
184
+
185
+    private func configureSnapshotMetadata(
186
+        _ snapshot: HealthSnapshot,
187
+        typeCounts: [TypeCount],
188
+        intendedTypeIDs: [String],
189
+        context: ModelContext
190
+    ) {
122 191
         // Chain metadata — set BEFORE context.save()
123 192
         // localSequenceNumber is used here solely to find the latest local candidate during
124 193
         // snapshot creation. Once previousSnapshotID is set, all chain reconstruction must use
@@ -129,8 +198,7 @@ final class HealthKitService {
129 198
             snapshot.localSequenceNumber = previous.localSequenceNumber + 1
130 199
             snapshot.isChainStart = false
131 200
 
132
-            let intentedTypeIDs = active.map { $0.id }
133
-            snapshot.monitoredTypeSetHash = HashService.typeSetHash(typeIDs: intentedTypeIDs)
201
+            snapshot.monitoredTypeSetHash = HashService.typeSetHash(typeIDs: intendedTypeIDs)
134 202
             if snapshot.monitoredTypeSetHash != previous.monitoredTypeSetHash {
135 203
                 snapshot.monitoredRegistryVersion = previous.monitoredRegistryVersion + 1
136 204
             } else {
@@ -146,7 +214,7 @@ final class HealthKitService {
146 214
             snapshot.previousSnapshotID = nil
147 215
             snapshot.localSequenceNumber = 0
148 216
             snapshot.isChainStart = true
149
-            snapshot.monitoredTypeSetHash = HashService.typeSetHash(typeIDs: active.map { $0.id })
217
+            snapshot.monitoredTypeSetHash = HashService.typeSetHash(typeIDs: intendedTypeIDs)
150 218
             snapshot.monitoredRegistryVersion = 0
151 219
 
152 220
             // Auto-detect post-restore on chain start with significant data
@@ -161,13 +229,6 @@ final class HealthKitService {
161 229
         // Device metadata — informational only, never used for chain linkage
162 230
         snapshot.hardwareModel = hardwareModel()
163 231
         snapshot.appBuildVersion = appBuildVersion()
164
-
165
-        try context.save()
166
-
167
-        // Post-save pipeline: delta computation + anomaly detection
168
-        try await runPostSavePipeline(snapshot: snapshot, typeCounts: typeCounts, context: context)
169
-
170
-        return snapshot
171 232
     }
172 233
 
173 234
     // MARK: - Post-save pipeline
+20 -7
HealthProbe/Utilities/SnapshotFetchProgress.swift
@@ -101,7 +101,8 @@ final class SnapshotFetchProgress {
101 101
         var successCount: Int = 0
102 102
     }
103 103
 
104
-    var types: [TypeProgress]
104
+    let totalTypeCount: Int
105
+    var types: [TypeProgress] = []
105 106
     var perTypeTimeoutSeconds: TimeInterval = 0
106 107
     var maxConcurrentTypeFetches: Int = 0
107 108
     var adaptiveTimeoutsEnabled: Bool = false
@@ -110,6 +111,7 @@ final class SnapshotFetchProgress {
110 111
     var snapshotChecksum: String = ""
111 112
     var monitoredTypeSetHash: String = ""
112 113
     var monitoredRegistryVersion: Int?
114
+    private let displayNamesByID: [String: String]
113 115
 
114 116
     var visibleTypes: [TypeProgress] { types }
115 117
     var completedCount: Int { types.filter { $0.status == .complete }.count }
@@ -124,9 +126,8 @@ final class SnapshotFetchProgress {
124 126
     }
125 127
 
126 128
     init(monitoredTypes: [(id: String, displayName: String)]) {
127
-        self.types = monitoredTypes.map {
128
-            TypeProgress(id: $0.id, displayName: $0.displayName)
129
-        }
129
+        self.totalTypeCount = monitoredTypes.count
130
+        self.displayNamesByID = Dictionary(uniqueKeysWithValues: monitoredTypes.map { ($0.id, $0.displayName) })
130 131
     }
131 132
 
132 133
     func updateConfiguration(
@@ -154,7 +155,7 @@ final class SnapshotFetchProgress {
154 155
     }
155 156
 
156 157
     func updateStatus(_ id: String, status: TypeProgress.FetchStatus, recordCount: Int? = nil) {
157
-        guard let index = types.firstIndex(where: { $0.id == id }) else { return }
158
+        let index = visibleTypeIndex(for: id)
158 159
         types[index].status = status
159 160
         if let recordCount {
160 161
             types[index].recordCount = recordCount
@@ -180,7 +181,7 @@ final class SnapshotFetchProgress {
180 181
         timeoutCount: Int,
181 182
         successCount: Int
182 183
     ) {
183
-        guard let index = types.firstIndex(where: { $0.id == id }) else { return }
184
+        let index = visibleTypeIndex(for: id)
184 185
         types[index].quality = quality
185 186
         types[index].recordCount = recordCount
186 187
         types[index].isUnsupported = isUnsupported
@@ -208,7 +209,7 @@ final class SnapshotFetchProgress {
208 209
         timeoutCount: Int,
209 210
         successCount: Int
210 211
     ) {
211
-        guard let index = types.firstIndex(where: { $0.id == id }) else { return }
212
+        let index = visibleTypeIndex(for: id)
212 213
         types[index].timeoutMode = timeoutMode
213 214
         types[index].lastSuccessfulElapsed = lastSuccessfulElapsed
214 215
         types[index].learnedTimeout = learnedTimeout
@@ -216,4 +217,16 @@ final class SnapshotFetchProgress {
216 217
         types[index].timeoutCount = timeoutCount
217 218
         types[index].successCount = successCount
218 219
     }
220
+
221
+    private func visibleTypeIndex(for id: String) -> Int {
222
+        if let index = types.firstIndex(where: { $0.id == id }) {
223
+            return index
224
+        }
225
+
226
+        types.insert(
227
+            TypeProgress(id: id, displayName: displayNamesByID[id] ?? id),
228
+            at: 0
229
+        )
230
+        return 0
231
+    }
219 232
 }
+50 -17
HealthProbe/ViewModels/DashboardViewModel.swift
@@ -25,6 +25,7 @@ final class DashboardViewModel {
25 25
 
26 26
     private let healthKit = HealthKitService.shared
27 27
     private let diffService = SnapshotDiffService.shared
28
+    private var pendingPartialSnapshot: HealthSnapshot?
28 29
 
29 30
     func requestAuthorization() async {
30 31
         isRequestingAuth = true
@@ -62,6 +63,7 @@ final class DashboardViewModel {
62 63
         completedSnapshotDeviceID = nil
63 64
         completedSnapshotTriggerReason = nil
64 65
         completedSnapshotRetryOfSnapshotID = nil
66
+        pendingPartialSnapshot = nil
65 67
         snapshotProgressMessage = ""
66 68
         snapshotProgressDetail = ""
67 69
         canRetryWithPermissions = false
@@ -69,6 +71,7 @@ final class DashboardViewModel {
69 71
 
70 72
         let monitoredTypes = HealthKitService.allTypes
71 73
             .filter { selectedTypeIDs.contains($0.id) }
74
+            .sortedByFetchDisplayNameDescending()
72 75
             .map { (id: $0.id, displayName: $0.displayName) }
73 76
         fetchProgress = SnapshotFetchProgress(monitoredTypes: monitoredTypes)
74 77
         fetchProgress?.updateConfiguration(
@@ -93,13 +96,6 @@ final class DashboardViewModel {
93 96
                 )
94 97
             }
95 98
 
96
-            let allSnapshots = try context.fetch(FetchDescriptor<HealthSnapshot>())
97
-            let exists = allSnapshots.contains { $0.id == snapshot.id }
98
-
99
-            if !exists {
100
-                throw SnapshotCreationError.snapshotNotSaved
101
-            }
102
-
103 99
             fetchDurationSeconds = fetchStartDate.map { Date().timeIntervalSince($0) }
104 100
             completedSnapshotID = snapshot.id
105 101
             completedSnapshotTimestamp = snapshot.timestamp
@@ -115,6 +111,8 @@ final class DashboardViewModel {
115 111
             )
116 112
 
117 113
             if snapshot.snapshotQuality != SnapshotQuality.complete {
114
+                pendingPartialSnapshot = snapshot
115
+
118 116
                 let typeCounts = snapshot.typeCounts ?? []
119 117
                 let unauthorizedCount = typeCounts.filter { $0.quality == SnapshotQuality.unauthorized }.count
120 118
                 let failedCount = typeCounts.filter { $0.quality == SnapshotQuality.failed }.count
@@ -159,6 +157,13 @@ final class DashboardViewModel {
159 157
                 return
160 158
             }
161 159
 
160
+            let allSnapshots = try context.fetch(FetchDescriptor<HealthSnapshot>())
161
+            let exists = allSnapshots.contains { $0.id == snapshot.id }
162
+
163
+            if !exists {
164
+                throw SnapshotCreationError.snapshotNotSaved
165
+            }
166
+
162 167
             snapshotProgress = .complete
163 168
         } catch is CancellationError {
164 169
             snapshotError = "Snapshot creation exceeded the operation timeout. Individual metric timeouts are adaptive; retry failed metrics with an extended timeout when available."
@@ -188,31 +193,59 @@ final class DashboardViewModel {
188 193
         }
189 194
     }
190 195
 
191
-    func retryFailedMetricsWithExtendedTimeout(context: ModelContext) async {
196
+    func retryFailedMetricsWithExtendedTimeout(
197
+        context: ModelContext,
198
+        selectedTypeIDs: Set<String>,
199
+        adaptiveTimeoutsEnabled: Bool
200
+    ) async {
192 201
         guard let progress = fetchProgress else { return }
193
-        let retryTypeIDs = Set(progress.types.compactMap { type -> String? in
194
-            guard case .failed(let reason) = type.status else { return nil }
195
-            return reason == "Timeout" ? type.id : nil
196
-        })
197
-        guard !retryTypeIDs.isEmpty else { return }
202
+        let hasTimeout = progress.types.contains { type in
203
+            guard case .failed(let reason) = type.status else { return false }
204
+            return reason == "Timeout"
205
+        }
206
+        guard hasTimeout else { return }
198 207
 
199 208
         await createSnapshot(
200 209
             context: context,
201
-            selectedTypeIDs: retryTypeIDs,
202
-            adaptiveTimeoutsEnabled: true,
210
+            selectedTypeIDs: selectedTypeIDs,
211
+            adaptiveTimeoutsEnabled: adaptiveTimeoutsEnabled,
203 212
             triggerReason: "retryFailedMetrics",
204
-            retryOfSnapshotID: completedSnapshotID,
205 213
             timeoutMultiplier: 2
206 214
         )
207 215
     }
208 216
 
209
-    func acceptPartialSnapshot() {
217
+    func savePartialSnapshot(context: ModelContext) async {
218
+        guard let snapshot = pendingPartialSnapshot else {
219
+            fetchProgress = nil
220
+            showProgressSheet = false
221
+            snapshotProgress = .idle
222
+            return
223
+        }
224
+
225
+        do {
226
+            let saved = try await healthKit.savePartialSnapshot(snapshot, in: context)
227
+            completedSnapshotID = saved.id
228
+            pendingPartialSnapshot = nil
229
+            fetchProgress?.updateChainContext(
230
+                previousSnapshotID: saved.previousSnapshotID,
231
+                isChainStart: saved.isChainStart,
232
+                snapshotChecksum: HashService.snapshotChecksum(typeCounts: saved.typeCounts ?? []),
233
+                monitoredTypeSetHash: saved.monitoredTypeSetHash,
234
+                monitoredRegistryVersion: saved.monitoredRegistryVersion
235
+            )
236
+        } catch {
237
+            snapshotError = "Failed to save partial snapshot: \(error.localizedDescription)"
238
+            showProgressSheet = true
239
+            return
240
+        }
241
+
210 242
         fetchProgress = nil
211 243
         showProgressSheet = false
212 244
         snapshotProgress = .idle
213 245
     }
214 246
 
215 247
     func discardSnapshot(context: ModelContext) async {
248
+        pendingPartialSnapshot = nil
216 249
         if let snapshotID = completedSnapshotID {
217 250
             do {
218 251
                 let allSnapshots = try context.fetch(FetchDescriptor<HealthSnapshot>())
+68 -30
HealthProbe/Views/Dashboard/DashboardView.swift
@@ -305,7 +305,7 @@ private func remediationNoteItems(progress: SnapshotFetchProgress) -> [String] {
305 305
         lines.append("")
306 306
         lines.append("STATISTICS")
307 307
         lines.append("Records:    \(progress.totalRecords)")
308
-        lines.append("Types:      \(progress.types.count) processed, \(progress.completedCount) complete, \(degraded.count) degraded")
308
+        lines.append("Types:      \(progress.types.count)/\(progress.totalTypeCount) processed, \(progress.completedCount) complete, \(degraded.count) degraded")
309 309
         lines.append("")
310 310
         lines.append(failedLines)
311 311
 
@@ -412,9 +412,9 @@ private func remediationNoteItems(progress: SnapshotFetchProgress) -> [String] {
412 412
 
413 413
     private func fetchProgressSummary(_ progress: SnapshotFetchProgress) -> String {
414 414
         if progress.failedCount > 0 {
415
-            return "\(progress.completedCount)/\(progress.types.count) fetched - \(progress.failedCount) failed"
415
+            return "\(progress.completedCount)/\(progress.totalTypeCount) fetched - \(progress.failedCount) failed"
416 416
         }
417
-        return "\(progress.completedCount)/\(progress.types.count) fetched"
417
+        return "\(progress.completedCount)/\(progress.totalTypeCount) fetched"
418 418
     }
419 419
 
420 420
     private func colorForStatus(_ status: SnapshotFetchProgress.TypeProgress.FetchStatus) -> Color {
@@ -502,7 +502,7 @@ private func remediationNoteItems(progress: SnapshotFetchProgress) -> [String] {
502 502
             .cornerRadius(8)
503 503
 
504 504
             VStack(spacing: 0) {
505
-                ReportRow(label: "Types processed", value: "\(progress.types.count)")
505
+                ReportRow(label: "Types processed", value: "\(progress.types.count)/\(progress.totalTypeCount)")
506 506
                 Rectangle().fill(Color(.separator)).frame(height: 0.5).padding(.leading, 16)
507 507
                 ReportRow(label: "Successful", value: "\(progress.completedCount)", valueColor: .healthyGreen)
508 508
                 if progress.failedCount > 0 {
@@ -607,16 +607,15 @@ private func remediationNoteItems(progress: SnapshotFetchProgress) -> [String] {
607 607
     }
608 608
 
609 609
     private func reportDiagnosticBlock(_ progress: SnapshotFetchProgress) -> some View {
610
-        let previewText = buildDiagnosticText(progress, mode: .compact)
611 610
         let fullText = buildDiagnosticText(progress, mode: .full)
612
-        return CollapsibleDiagnosticBlock(previewText: previewText, fullText: fullText)
611
+        return CollapsibleDiagnosticBlock(fullText: fullText)
613 612
     }
614 613
 
615 614
     private func reportDecisionOverviewSection() -> some View {
616 615
         reportRemediationSection(
617 616
             [
618
-                "Retry failed metric with extended timeout.",
619
-                "Accept snapshot if partial data is sufficient.",
617
+                "Retry the full snapshot with extended timeout.",
618
+                "Save the partial snapshot only if this incomplete report is useful.",
620 619
                 "Discard snapshot and start a new one.",
621 620
                 "Disable this metric in Settings if it consistently times out."
622 621
             ],
@@ -777,7 +776,7 @@ private func remediationNoteItems(progress: SnapshotFetchProgress) -> [String] {
777 776
             }
778 777
 
779 778
             if let progress = viewModel.fetchProgress {
780
-                Text("\(progress.types.count) metrics")
779
+                Text("\(progress.totalTypeCount) metrics")
781 780
                     .font(.caption)
782 781
                     .foregroundStyle(.secondary)
783 782
             }
@@ -869,7 +868,11 @@ private func remediationNoteItems(progress: SnapshotFetchProgress) -> [String] {
869 868
                             adaptiveTimeoutsEnabled: appSettings.adaptiveTimeoutsEnabled
870 869
                         )
871 870
                     } else {
872
-                        await viewModel.retryFailedMetricsWithExtendedTimeout(context: modelContext)
871
+                        await viewModel.retryFailedMetricsWithExtendedTimeout(
872
+                            context: modelContext,
873
+                            selectedTypeIDs: appSettings.selectedTypeIDs,
874
+                            adaptiveTimeoutsEnabled: appSettings.adaptiveTimeoutsEnabled
875
+                        )
873 876
                     }
874 877
                 }
875 878
             } label: {
@@ -879,12 +882,13 @@ private func remediationNoteItems(progress: SnapshotFetchProgress) -> [String] {
879 882
             .disabled(viewModel.isCreatingSnapshot || viewModel.isRequestingAuth)
880 883
 
881 884
             Button {
882
-                viewModel.acceptPartialSnapshot()
885
+                Task { await viewModel.savePartialSnapshot(context: modelContext) }
883 886
             } label: {
884
-                Text("Accept").frame(maxWidth: .infinity)
887
+                Text("Save Partial").frame(maxWidth: .infinity)
885 888
             }
886 889
             .buttonStyle(.bordered)
887
-            .accessibilityLabel("Accept partial snapshot")
890
+            .disabled(viewModel.isCreatingSnapshot)
891
+            .accessibilityLabel("Save partial snapshot")
888 892
 
889 893
             Button {
890 894
                 Task { await viewModel.discardSnapshot(context: modelContext) }
@@ -1016,33 +1020,67 @@ private struct ReportRow: View {
1016 1020
 }
1017 1021
 
1018 1022
 private struct CollapsibleDiagnosticBlock: View {
1019
-    let previewText: String
1020 1023
     let fullText: String
1021 1024
     @State private var isExpanded = false
1025
+    @State private var didCopy = false
1022 1026
 
1023 1027
     var body: some View {
1024 1028
         VStack(alignment: .leading, spacing: 8) {
1025
-            Button {
1026
-                withAnimation(.snappy) {
1027
-                    isExpanded.toggle()
1028
-                }
1029
-            } label: {
1030
-                HStack {
1029
+            HStack(spacing: 12) {
1030
+                Button {
1031
+                    withAnimation(.snappy) {
1032
+                        isExpanded.toggle()
1033
+                    }
1034
+                } label: {
1031 1035
                     Label("Diagnostics", systemImage: "doc.text.magnifyingglass")
1032
-                    Spacer()
1036
+                }
1037
+                .buttonStyle(.plain)
1038
+
1039
+                Spacer()
1040
+
1041
+                Button {
1042
+                    copyDiagnostics()
1043
+                } label: {
1044
+                    Label(didCopy ? "Copied" : "Copy", systemImage: didCopy ? "checkmark" : "doc.on.doc")
1045
+                        .font(.subheadline.weight(.semibold))
1046
+                }
1047
+                .buttonStyle(.plain)
1048
+                .accessibilityLabel(didCopy ? "Diagnostics copied" : "Copy diagnostics")
1049
+
1050
+                Button {
1051
+                    withAnimation(.snappy) {
1052
+                        isExpanded.toggle()
1053
+                    }
1054
+                } label: {
1033 1055
                     Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
1034 1056
                         .font(.caption.weight(.semibold))
1057
+                        .frame(width: 28, height: 28)
1035 1058
                 }
1059
+                .buttonStyle(.plain)
1060
+                .accessibilityLabel(isExpanded ? "Collapse diagnostics" : "Expand diagnostics")
1061
+            }
1062
+
1063
+            if isExpanded {
1064
+                Text(fullText)
1065
+                    .font(.system(.caption, design: .monospaced))
1066
+                    .textSelection(.enabled)
1067
+                    .frame(maxWidth: .infinity, alignment: .leading)
1068
+                    .padding(10)
1069
+                    .background(Color(.secondarySystemGroupedBackground))
1070
+                    .clipShape(RoundedRectangle(cornerRadius: 8))
1071
+            }
1072
+        }
1073
+    }
1074
+
1075
+    private func copyDiagnostics() {
1076
+        UIPasteboard.general.string = fullText
1077
+        withAnimation(.snappy) {
1078
+            didCopy = true
1079
+        }
1080
+        DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
1081
+            withAnimation(.snappy) {
1082
+                didCopy = false
1036 1083
             }
1037
-            .buttonStyle(.plain)
1038
-
1039
-            Text(isExpanded ? fullText : previewText)
1040
-                .font(.system(.caption, design: .monospaced))
1041
-                .textSelection(.enabled)
1042
-                .frame(maxWidth: .infinity, alignment: .leading)
1043
-                .padding(10)
1044
-                .background(Color(.secondarySystemGroupedBackground))
1045
-                .clipShape(RoundedRectangle(cornerRadius: 8))
1046 1084
         }
1047 1085
     }
1048 1086
 }