@@ -599,6 +599,7 @@ rows exist". |
||
| 599 | 599 |
| 2026-06-05 | `e7d45a2` | Count observation events from the event table first. | Confirmed on a full-profile repeated capture with `buildFingerprint: 1.0(1)-1780646299-92064`: wall clock `15.6s`, `127/127` complete, `CaptureModes: unchangedDelta=114, delta=13`, and `DeltaEvents: 99`. `SummedFinalizeEventCountElapsed` dropped from the post-`bf5a861` overnight baseline `2.5s` to `0.0s`; total finalize dropped to `1.7s`, while daily aggregate rebuild stayed `0.0s`. Remaining cost is processing: `9.1s` total, led by Heart Rate `4.6s` for `18` events, Active Energy `1.8s` for `11` events, and Basal Energy `1.5s` for `5` events. | |
| 600 | 600 |
| 2026-06-05 | `cfd9de8` | Split processing timing diagnostics. | Confirmed on a full-profile repeated capture with `buildFingerprint: 1.0(1)-1780683224-92064`: wall clock `14.7s`, `127/127` complete, `CaptureModes: unchangedDelta=120, delta=7`, and `DeltaEvents: 11`. `SummedProcessingElapsed` was `8.5s` and `SummedProcessingRecordArchiveRebuildElapsed` was also `8.5s`; delta apply, initial record processing, record archive finalization, and processing other all rounded to `0.0s`. Per-type rebuild cost dominated changed high-volume metrics: Heart Rate `4.4s`, Active Energy `1.7s`, Basal Energy `1.5s`, Steps `0.4s`, and Walking + Running Distance `0.4s`. Conclusion: the repeated-capture bottleneck is no longer SQLite finalization; it is the legacy compact `recordArchiveData` rebuild for changed types. | |
| 601 | 601 |
| 2026-06-05 | `0db2f5e` | Skip legacy compact archive rebuild for SQLite-backed deltas. | Confirmed on two full-profile repeated captures. First report, `buildFingerprint: 1.0(1)-1780689289-92064`, completed in `6.2s` with `127/127` complete, `CaptureModes: unchangedDelta=118, delta=9`, `DeltaEvents: 116`, `SummedProcessingElapsed: 0.0s`, and `SummedProcessingRecordArchiveRebuildElapsed: 0.0s`; Heart Rate and Active Energy each completed in `0.2s` with no rebuild. Second report, `buildFingerprint: 1.0(1)-1780689890-92064`, completed in `5.8s` with `CaptureModes: unchangedDelta=121, delta=6`, `DeltaEvents: 7`, fetch `2.0s`, insert `0.1s`, finalize `1.7s`, residual `1.1s`, and processing/rebuild still `0.0s`. Conclusion: the legacy compact archive rebuild was the repeated-capture bottleneck and is now removed from the normal SQLite-backed delta path; the repeated full-profile floor is now roughly `6s` on this device/database. | |
| 602 |
+| 2026-06-06 | pending | Expose saved diagnostic reports in Settings. | Diagnostic reports were already persisted under Application Support at snapshot completion, but they were not discoverable from the app after dismissing the result sheet. Settings now lists the latest saved reports, opens them with the same chunked lazy diagnostics viewer, supports copy, and allows per-report deletion. Expected signal: future import analysis can recover the exact report text after the fact instead of depending on screenshots or manual copy at completion time. | |
|
| 602 | 603 |
|
| 603 | 604 |
## Current Diagnosis |
| 604 | 605 |
|
@@ -812,7 +813,8 @@ The likely bottleneck is per-row SQLite work: |
||
| 812 | 813 |
- A previous Heart Rate import appeared to stall for long periods around roughly 900k records, but later progress resumed; avoid classifying this as a hard timeout without report evidence. |
| 813 | 814 |
- Diagnostic reports must remain available after each import. Copying from the |
| 814 | 815 |
Diagnostics sheet is useful, but future comparisons should not depend on the |
| 815 |
- operator remembering to copy the visible report. |
|
| 816 |
+ operator remembering to copy the visible report. Saved reports are now exposed |
|
| 817 |
+ in Settings so the exact text can be recovered later. |
|
| 816 | 818 |
- After a completed import, the app may remain unresponsive or crash in legacy |
| 817 | 819 |
post-import cache work. A 2026-06-03 console log showed Heart Rate and Active |
| 818 | 820 |
Energy `TypeCount.detailCacheData` precompute immediately before a Core Data |
@@ -0,0 +1,97 @@ |
||
| 1 |
+import Foundation |
|
| 2 |
+ |
|
| 3 |
+struct DiagnosticReportFile: Identifiable, Equatable {
|
|
| 4 |
+ let url: URL |
|
| 5 |
+ let createdAt: Date |
|
| 6 |
+ let sizeBytes: Int64 |
|
| 7 |
+ |
|
| 8 |
+ var id: String { url.path }
|
|
| 9 |
+ var filename: String { url.lastPathComponent }
|
|
| 10 |
+ |
|
| 11 |
+ var displayTitle: String {
|
|
| 12 |
+ filename |
|
| 13 |
+ .replacingOccurrences(of: ".txt", with: "") |
|
| 14 |
+ .replacingOccurrences(of: "_", with: " ") |
|
| 15 |
+ } |
|
| 16 |
+ |
|
| 17 |
+ var displaySubtitle: String {
|
|
| 18 |
+ let size = ByteCountFormatter.string(fromByteCount: sizeBytes, countStyle: .file) |
|
| 19 |
+ return "\(DiagnosticReportStore.displayFormatter.string(from: createdAt)) - \(size)" |
|
| 20 |
+ } |
|
| 21 |
+} |
|
| 22 |
+ |
|
| 23 |
+enum DiagnosticReportStore {
|
|
| 24 |
+ static let directory = URL.applicationSupportDirectory |
|
| 25 |
+ .appending(path: "HealthProbeDiagnostics", directoryHint: .isDirectory) |
|
| 26 |
+ |
|
| 27 |
+ static let displayFormatter: DateFormatter = {
|
|
| 28 |
+ let formatter = DateFormatter() |
|
| 29 |
+ formatter.dateStyle = .medium |
|
| 30 |
+ formatter.timeStyle = .short |
|
| 31 |
+ return formatter |
|
| 32 |
+ }() |
|
| 33 |
+ |
|
| 34 |
+ static func persist(text: String, snapshotID: UUID, operationID: UUID?) throws {
|
|
| 35 |
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) |
|
| 36 |
+ |
|
| 37 |
+ let timestamp = Self.filenameTimestamp(Date()) |
|
| 38 |
+ let operationPart = operationID.map { "_operation-\($0.uuidString)" } ?? ""
|
|
| 39 |
+ let filename = "\(timestamp)_snapshot-\(snapshotID.uuidString)\(operationPart).txt" |
|
| 40 |
+ let fileURL = directory.appending(path: filename, directoryHint: .notDirectory) |
|
| 41 |
+ try text.write(to: fileURL, atomically: true, encoding: .utf8) |
|
| 42 |
+ } |
|
| 43 |
+ |
|
| 44 |
+ static func list(limit: Int? = nil) throws -> [DiagnosticReportFile] {
|
|
| 45 |
+ guard FileManager.default.fileExists(atPath: directory.path) else {
|
|
| 46 |
+ return [] |
|
| 47 |
+ } |
|
| 48 |
+ |
|
| 49 |
+ let urls = try FileManager.default.contentsOfDirectory( |
|
| 50 |
+ at: directory, |
|
| 51 |
+ includingPropertiesForKeys: [.creationDateKey, .contentModificationDateKey, .fileSizeKey], |
|
| 52 |
+ options: [.skipsHiddenFiles] |
|
| 53 |
+ ) |
|
| 54 |
+ |
|
| 55 |
+ let reports = urls |
|
| 56 |
+ .filter { $0.pathExtension == "txt" }
|
|
| 57 |
+ .compactMap(reportFile) |
|
| 58 |
+ .sorted { lhs, rhs in
|
|
| 59 |
+ if lhs.createdAt == rhs.createdAt {
|
|
| 60 |
+ return lhs.filename > rhs.filename |
|
| 61 |
+ } |
|
| 62 |
+ return lhs.createdAt > rhs.createdAt |
|
| 63 |
+ } |
|
| 64 |
+ |
|
| 65 |
+ if let limit {
|
|
| 66 |
+ return Array(reports.prefix(limit)) |
|
| 67 |
+ } |
|
| 68 |
+ return reports |
|
| 69 |
+ } |
|
| 70 |
+ |
|
| 71 |
+ static func read(_ report: DiagnosticReportFile) throws -> String {
|
|
| 72 |
+ try String(contentsOf: report.url, encoding: .utf8) |
|
| 73 |
+ } |
|
| 74 |
+ |
|
| 75 |
+ static func delete(_ report: DiagnosticReportFile) throws {
|
|
| 76 |
+ try FileManager.default.removeItem(at: report.url) |
|
| 77 |
+ } |
|
| 78 |
+ |
|
| 79 |
+ private static func reportFile(_ url: URL) -> DiagnosticReportFile? {
|
|
| 80 |
+ guard let values = try? url.resourceValues(forKeys: [.creationDateKey, .contentModificationDateKey, .fileSizeKey]) else {
|
|
| 81 |
+ return nil |
|
| 82 |
+ } |
|
| 83 |
+ return DiagnosticReportFile( |
|
| 84 |
+ url: url, |
|
| 85 |
+ createdAt: values.creationDate ?? values.contentModificationDate ?? Date.distantPast, |
|
| 86 |
+ sizeBytes: Int64(values.fileSize ?? 0) |
|
| 87 |
+ ) |
|
| 88 |
+ } |
|
| 89 |
+ |
|
| 90 |
+ private static func filenameTimestamp(_ date: Date) -> String {
|
|
| 91 |
+ let formatter = DateFormatter() |
|
| 92 |
+ formatter.locale = Locale(identifier: "en_US_POSIX") |
|
| 93 |
+ formatter.timeZone = TimeZone(secondsFromGMT: 0) |
|
| 94 |
+ formatter.dateFormat = "yyyyMMdd_HHmmss" |
|
| 95 |
+ return formatter.string(from: date) |
|
| 96 |
+ } |
|
| 97 |
+} |
|
@@ -0,0 +1,74 @@ |
||
| 1 |
+import SwiftUI |
|
| 2 |
+ |
|
| 3 |
+struct DiagnosticReportSheet: View {
|
|
| 4 |
+ @Environment(\.dismiss) private var dismiss |
|
| 5 |
+ let reportText: String |
|
| 6 |
+ @State private var didCopy = false |
|
| 7 |
+ |
|
| 8 |
+ var body: some View {
|
|
| 9 |
+ NavigationStack {
|
|
| 10 |
+ ScrollView {
|
|
| 11 |
+ if reportText.isEmpty {
|
|
| 12 |
+ ContentUnavailableView("No diagnostics available", systemImage: "doc.text.magnifyingglass")
|
|
| 13 |
+ .padding(24) |
|
| 14 |
+ } else {
|
|
| 15 |
+ LazyVStack(alignment: .leading, spacing: 0) {
|
|
| 16 |
+ ForEach(Array(Self.textChunks(reportText).enumerated()), id: \.offset) { _, chunk in
|
|
| 17 |
+ Text(chunk) |
|
| 18 |
+ .font(.system(.caption, design: .monospaced)) |
|
| 19 |
+ .foregroundStyle(.primary) |
|
| 20 |
+ .textSelection(.enabled) |
|
| 21 |
+ .frame(maxWidth: .infinity, alignment: .leading) |
|
| 22 |
+ } |
|
| 23 |
+ } |
|
| 24 |
+ .padding(16) |
|
| 25 |
+ } |
|
| 26 |
+ } |
|
| 27 |
+ .background(Color(.systemGroupedBackground)) |
|
| 28 |
+ .navigationTitle("Diagnostics")
|
|
| 29 |
+ .navigationBarTitleDisplayMode(.inline) |
|
| 30 |
+ .toolbar {
|
|
| 31 |
+ ToolbarItem(placement: .cancellationAction) {
|
|
| 32 |
+ Button("Done") {
|
|
| 33 |
+ dismiss() |
|
| 34 |
+ } |
|
| 35 |
+ } |
|
| 36 |
+ ToolbarItem(placement: .primaryAction) {
|
|
| 37 |
+ Button {
|
|
| 38 |
+ copyDiagnostics() |
|
| 39 |
+ } label: {
|
|
| 40 |
+ Label(didCopy ? "Copied" : "Copy", systemImage: didCopy ? "checkmark" : "doc.on.doc") |
|
| 41 |
+ } |
|
| 42 |
+ .accessibilityLabel(didCopy ? "Diagnostics copied" : "Copy diagnostics") |
|
| 43 |
+ } |
|
| 44 |
+ } |
|
| 45 |
+ } |
|
| 46 |
+ .presentationDetents([.large]) |
|
| 47 |
+ .presentationDragIndicator(.visible) |
|
| 48 |
+ } |
|
| 49 |
+ |
|
| 50 |
+ private func copyDiagnostics() {
|
|
| 51 |
+ UIPasteboard.general.string = reportText |
|
| 52 |
+ withAnimation(.snappy) {
|
|
| 53 |
+ didCopy = true |
|
| 54 |
+ } |
|
| 55 |
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
|
|
| 56 |
+ withAnimation(.snappy) {
|
|
| 57 |
+ didCopy = false |
|
| 58 |
+ } |
|
| 59 |
+ } |
|
| 60 |
+ } |
|
| 61 |
+ |
|
| 62 |
+ private static func textChunks(_ text: String, chunkSize: Int = 4_000) -> [String] {
|
|
| 63 |
+ guard !text.isEmpty else { return [] }
|
|
| 64 |
+ var chunks: [String] = [] |
|
| 65 |
+ chunks.reserveCapacity(max(1, text.count / chunkSize)) |
|
| 66 |
+ var start = text.startIndex |
|
| 67 |
+ while start < text.endIndex {
|
|
| 68 |
+ let end = text.index(start, offsetBy: chunkSize, limitedBy: text.endIndex) ?? text.endIndex |
|
| 69 |
+ chunks.append(String(text[start..<end])) |
|
| 70 |
+ start = end |
|
| 71 |
+ } |
|
| 72 |
+ return chunks |
|
| 73 |
+ } |
|
| 74 |
+} |
|
@@ -1340,28 +1340,6 @@ private struct DiagnosticReport: Identifiable {
|
||
| 1340 | 1340 |
let text: String |
| 1341 | 1341 |
} |
| 1342 | 1342 |
|
| 1343 |
-private enum DiagnosticReportStore {
|
|
| 1344 |
- static func persist(text: String, snapshotID: UUID, operationID: UUID?) throws {
|
|
| 1345 |
- let directory = URL.applicationSupportDirectory |
|
| 1346 |
- .appending(path: "HealthProbeDiagnostics", directoryHint: .isDirectory) |
|
| 1347 |
- try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) |
|
| 1348 |
- |
|
| 1349 |
- let timestamp = Self.filenameTimestamp(Date()) |
|
| 1350 |
- let operationPart = operationID.map { "_operation-\($0.uuidString)" } ?? ""
|
|
| 1351 |
- let filename = "\(timestamp)_snapshot-\(snapshotID.uuidString)\(operationPart).txt" |
|
| 1352 |
- let fileURL = directory.appending(path: filename, directoryHint: .notDirectory) |
|
| 1353 |
- try text.write(to: fileURL, atomically: true, encoding: .utf8) |
|
| 1354 |
- } |
|
| 1355 |
- |
|
| 1356 |
- private static func filenameTimestamp(_ date: Date) -> String {
|
|
| 1357 |
- let formatter = DateFormatter() |
|
| 1358 |
- formatter.locale = Locale(identifier: "en_US_POSIX") |
|
| 1359 |
- formatter.timeZone = TimeZone(secondsFromGMT: 0) |
|
| 1360 |
- formatter.dateFormat = "yyyyMMdd_HHmmss" |
|
| 1361 |
- return formatter.string(from: date) |
|
| 1362 |
- } |
|
| 1363 |
-} |
|
| 1364 |
- |
|
| 1365 | 1343 |
// Avoids LabeledContent's TableRowContent ambiguity in List/Section contexts. |
| 1366 | 1344 |
private struct InfoRow<Content: View>: View {
|
| 1367 | 1345 |
let label: String |
@@ -1426,79 +1404,6 @@ private struct ReportRow: View {
|
||
| 1426 | 1404 |
} |
| 1427 | 1405 |
} |
| 1428 | 1406 |
|
| 1429 |
-private struct DiagnosticReportSheet: View {
|
|
| 1430 |
- @Environment(\.dismiss) private var dismiss |
|
| 1431 |
- let reportText: String |
|
| 1432 |
- @State private var didCopy = false |
|
| 1433 |
- |
|
| 1434 |
- var body: some View {
|
|
| 1435 |
- NavigationStack {
|
|
| 1436 |
- ScrollView {
|
|
| 1437 |
- if reportText.isEmpty {
|
|
| 1438 |
- ContentUnavailableView("No diagnostics available", systemImage: "doc.text.magnifyingglass")
|
|
| 1439 |
- .padding(24) |
|
| 1440 |
- } else {
|
|
| 1441 |
- LazyVStack(alignment: .leading, spacing: 0) {
|
|
| 1442 |
- ForEach(Array(Self.textChunks(reportText).enumerated()), id: \.offset) { _, chunk in
|
|
| 1443 |
- Text(chunk) |
|
| 1444 |
- .font(.system(.caption, design: .monospaced)) |
|
| 1445 |
- .foregroundStyle(.primary) |
|
| 1446 |
- .textSelection(.enabled) |
|
| 1447 |
- .frame(maxWidth: .infinity, alignment: .leading) |
|
| 1448 |
- } |
|
| 1449 |
- } |
|
| 1450 |
- .padding(16) |
|
| 1451 |
- } |
|
| 1452 |
- } |
|
| 1453 |
- .background(Color(.systemGroupedBackground)) |
|
| 1454 |
- .navigationTitle("Diagnostics")
|
|
| 1455 |
- .navigationBarTitleDisplayMode(.inline) |
|
| 1456 |
- .toolbar {
|
|
| 1457 |
- ToolbarItem(placement: .cancellationAction) {
|
|
| 1458 |
- Button("Done") {
|
|
| 1459 |
- dismiss() |
|
| 1460 |
- } |
|
| 1461 |
- } |
|
| 1462 |
- ToolbarItem(placement: .primaryAction) {
|
|
| 1463 |
- Button {
|
|
| 1464 |
- copyDiagnostics() |
|
| 1465 |
- } label: {
|
|
| 1466 |
- Label(didCopy ? "Copied" : "Copy", systemImage: didCopy ? "checkmark" : "doc.on.doc") |
|
| 1467 |
- } |
|
| 1468 |
- .accessibilityLabel(didCopy ? "Diagnostics copied" : "Copy diagnostics") |
|
| 1469 |
- } |
|
| 1470 |
- } |
|
| 1471 |
- } |
|
| 1472 |
- .presentationDetents([.large]) |
|
| 1473 |
- .presentationDragIndicator(.visible) |
|
| 1474 |
- } |
|
| 1475 |
- |
|
| 1476 |
- private func copyDiagnostics() {
|
|
| 1477 |
- UIPasteboard.general.string = reportText |
|
| 1478 |
- withAnimation(.snappy) {
|
|
| 1479 |
- didCopy = true |
|
| 1480 |
- } |
|
| 1481 |
- DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
|
|
| 1482 |
- withAnimation(.snappy) {
|
|
| 1483 |
- didCopy = false |
|
| 1484 |
- } |
|
| 1485 |
- } |
|
| 1486 |
- } |
|
| 1487 |
- |
|
| 1488 |
- private static func textChunks(_ text: String, chunkSize: Int = 4_000) -> [String] {
|
|
| 1489 |
- guard !text.isEmpty else { return [] }
|
|
| 1490 |
- var chunks: [String] = [] |
|
| 1491 |
- chunks.reserveCapacity(max(1, text.count / chunkSize)) |
|
| 1492 |
- var start = text.startIndex |
|
| 1493 |
- while start < text.endIndex {
|
|
| 1494 |
- let end = text.index(start, offsetBy: chunkSize, limitedBy: text.endIndex) ?? text.endIndex |
|
| 1495 |
- chunks.append(String(text[start..<end])) |
|
| 1496 |
- start = end |
|
| 1497 |
- } |
|
| 1498 |
- return chunks |
|
| 1499 |
- } |
|
| 1500 |
-} |
|
| 1501 |
- |
|
| 1502 | 1407 |
extension Bundle {
|
| 1503 | 1408 |
var appVersion: String {
|
| 1504 | 1409 |
guard let version = infoDictionary?["CFBundleShortVersionString"] as? String else {
|
@@ -10,6 +10,8 @@ struct SettingsView: View {
|
||
| 10 | 10 |
@State private var timeoutProfiles: [LocalMetricTimeoutProfile] = [] |
| 11 | 11 |
@State private var currentDeviceProfile: LocalDeviceProfile? |
| 12 | 12 |
@State private var resetScheduled = PrototypeStoreResetPolicy.isResetScheduled(appSupportURL: .applicationSupportDirectory) |
| 13 |
+ @State private var diagnosticReports: [DiagnosticReportFile] = [] |
|
| 14 |
+ @State private var diagnosticReportText: DiagnosticReportText? |
|
| 13 | 15 |
|
| 14 | 16 |
private var currentDeviceID: String {
|
| 15 | 17 |
AppSettings.currentDeviceID |
@@ -24,6 +26,7 @@ struct SettingsView: View {
|
||
| 24 | 26 |
timeoutCalibrationSection |
| 25 | 27 |
typeSelectionSections |
| 26 | 28 |
dataSection |
| 29 |
+ diagnosticReportsSection |
|
| 27 | 30 |
aboutSection |
| 28 | 31 |
} |
| 29 | 32 |
.navigationTitle("Settings")
|
@@ -31,6 +34,10 @@ struct SettingsView: View {
|
||
| 31 | 34 |
loadCurrentDeviceProfile() |
| 32 | 35 |
loadTimeoutProfiles() |
| 33 | 36 |
loadArchiveCacheStatus() |
| 37 |
+ loadDiagnosticReports() |
|
| 38 |
+ } |
|
| 39 |
+ .sheet(item: $diagnosticReportText) { report in
|
|
| 40 |
+ DiagnosticReportSheet(reportText: report.text) |
|
| 34 | 41 |
} |
| 35 | 42 |
.confirmationDialog( |
| 36 | 43 |
"Delete Rebuildable UI Cache", |
@@ -247,6 +254,44 @@ struct SettingsView: View {
|
||
| 247 | 254 |
} |
| 248 | 255 |
} |
| 249 | 256 |
|
| 257 |
+ private var diagnosticReportsSection: some View {
|
|
| 258 |
+ Section("Diagnostic Reports") {
|
|
| 259 |
+ if diagnosticReports.isEmpty {
|
|
| 260 |
+ Text("No saved reports yet")
|
|
| 261 |
+ .foregroundStyle(.secondary) |
|
| 262 |
+ } else {
|
|
| 263 |
+ ForEach(diagnosticReports) { report in
|
|
| 264 |
+ Button {
|
|
| 265 |
+ openDiagnosticReport(report) |
|
| 266 |
+ } label: {
|
|
| 267 |
+ VStack(alignment: .leading, spacing: 3) {
|
|
| 268 |
+ Text(report.displayTitle) |
|
| 269 |
+ .font(.subheadline.weight(.semibold)) |
|
| 270 |
+ .foregroundStyle(.primary) |
|
| 271 |
+ .lineLimit(2) |
|
| 272 |
+ Text(report.displaySubtitle) |
|
| 273 |
+ .font(.caption) |
|
| 274 |
+ .foregroundStyle(.secondary) |
|
| 275 |
+ } |
|
| 276 |
+ } |
|
| 277 |
+ .swipeActions {
|
|
| 278 |
+ Button(role: .destructive) {
|
|
| 279 |
+ deleteDiagnosticReport(report) |
|
| 280 |
+ } label: {
|
|
| 281 |
+ Label("Delete", systemImage: "trash")
|
|
| 282 |
+ } |
|
| 283 |
+ } |
|
| 284 |
+ } |
|
| 285 |
+ } |
|
| 286 |
+ |
|
| 287 |
+ Button {
|
|
| 288 |
+ loadDiagnosticReports() |
|
| 289 |
+ } label: {
|
|
| 290 |
+ Label("Refresh Reports", systemImage: "arrow.clockwise")
|
|
| 291 |
+ } |
|
| 292 |
+ } |
|
| 293 |
+ } |
|
| 294 |
+ |
|
| 250 | 295 |
private var aboutSection: some View {
|
| 251 | 296 |
Section("About") {
|
| 252 | 297 |
InfoRow(label: "Version") {
|
@@ -325,6 +370,34 @@ struct SettingsView: View {
|
||
| 325 | 370 |
} |
| 326 | 371 |
} |
| 327 | 372 |
|
| 373 |
+ private func loadDiagnosticReports() {
|
|
| 374 |
+ do {
|
|
| 375 |
+ diagnosticReports = try DiagnosticReportStore.list(limit: 20) |
|
| 376 |
+ } catch {
|
|
| 377 |
+ dataMaintenanceMessage = "Diagnostic report list failed: \(error.localizedDescription)" |
|
| 378 |
+ diagnosticReports = [] |
|
| 379 |
+ } |
|
| 380 |
+ } |
|
| 381 |
+ |
|
| 382 |
+ private func openDiagnosticReport(_ report: DiagnosticReportFile) {
|
|
| 383 |
+ do {
|
|
| 384 |
+ diagnosticReportText = DiagnosticReportText(text: try DiagnosticReportStore.read(report)) |
|
| 385 |
+ } catch {
|
|
| 386 |
+ dataMaintenanceMessage = "Diagnostic report open failed: \(error.localizedDescription)" |
|
| 387 |
+ loadDiagnosticReports() |
|
| 388 |
+ } |
|
| 389 |
+ } |
|
| 390 |
+ |
|
| 391 |
+ private func deleteDiagnosticReport(_ report: DiagnosticReportFile) {
|
|
| 392 |
+ do {
|
|
| 393 |
+ try DiagnosticReportStore.delete(report) |
|
| 394 |
+ loadDiagnosticReports() |
|
| 395 |
+ } catch {
|
|
| 396 |
+ dataMaintenanceMessage = "Diagnostic report delete failed: \(error.localizedDescription)" |
|
| 397 |
+ loadDiagnosticReports() |
|
| 398 |
+ } |
|
| 399 |
+ } |
|
| 400 |
+ |
|
| 328 | 401 |
private func rebuildArchiveCache() {
|
| 329 | 402 |
do {
|
| 330 | 403 |
let summary = try CoreDataArchiveCacheStore().rebuild(fromArchiveAt: SQLiteHealthArchiveStore.defaultDatabaseURL) |
@@ -356,6 +429,11 @@ struct SettingsView: View {
|
||
| 356 | 429 |
|
| 357 | 430 |
// MARK: - Subviews |
| 358 | 431 |
|
| 432 |
+private struct DiagnosticReportText: Identifiable {
|
|
| 433 |
+ let id = UUID() |
|
| 434 |
+ let text: String |
|
| 435 |
+} |
|
| 436 |
+ |
|
| 359 | 437 |
private struct TypeToggleRow: View {
|
| 360 | 438 |
let type: MonitoredType |
| 361 | 439 |
let appSettings: AppSettings |