diff --git a/LoopSupport.xcodeproj/project.pbxproj b/LoopSupport.xcodeproj/project.pbxproj index 679fccf..6214432 100644 --- a/LoopSupport.xcodeproj/project.pbxproj +++ b/LoopSupport.xcodeproj/project.pbxproj @@ -23,6 +23,7 @@ C12ECD06289560B90089EB07 /* LoopSupportKitUI.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = A936A95425BC9A190085119C /* LoopSupportKitUI.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; C1A55E022E9F000000B10002 /* IssueReportContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A55E022E9F000000B10001 /* IssueReportContext.swift */; }; C1A55E022E9F000000B10004 /* IssueReportSubmitter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A55E022E9F000000B10003 /* IssueReportSubmitter.swift */; }; + C1A55E022E9F000000B1000A /* DiagnosticReportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A55E022E9F000000B10009 /* DiagnosticReportView.swift */; }; C1A55E022E9F000000B10006 /* ReportProblemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A55E022E9F000000B10005 /* ReportProblemView.swift */; }; C1A55E022E9F000000B10008 /* SendIssueReportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A55E022E9F000000B10007 /* SendIssueReportView.swift */; }; C14A7E7C295F2EC100CD87B4 /* UsageDataPrivacyPreferenceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C14A7E7B295F2EC100CD87B4 /* UsageDataPrivacyPreferenceView.swift */; }; @@ -90,6 +91,7 @@ B66D1F542E6A752300471149 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; C1A55E022E9F000000B10001 /* IssueReportContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IssueReportContext.swift; sourceTree = ""; }; C1A55E022E9F000000B10003 /* IssueReportSubmitter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IssueReportSubmitter.swift; sourceTree = ""; }; + C1A55E022E9F000000B10009 /* DiagnosticReportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiagnosticReportView.swift; sourceTree = ""; }; C1A55E022E9F000000B10005 /* ReportProblemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportProblemView.swift; sourceTree = ""; }; C1A55E022E9F000000B10007 /* SendIssueReportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendIssueReportView.swift; sourceTree = ""; }; C14A7E7B295F2EC100CD87B4 /* UsageDataPrivacyPreferenceView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UsageDataPrivacyPreferenceView.swift; sourceTree = ""; }; @@ -197,6 +199,7 @@ C1A55E022E9F000000B10000 /* IssueReport */ = { isa = PBXGroup; children = ( + C1A55E022E9F000000B10009 /* DiagnosticReportView.swift */, C1A55E022E9F000000B10001 /* IssueReportContext.swift */, C1A55E022E9F000000B10003 /* IssueReportSubmitter.swift */, C1A55E022E9F000000B10005 /* ReportProblemView.swift */, @@ -371,6 +374,7 @@ C14A7E7C295F2EC100CD87B4 /* UsageDataPrivacyPreferenceView.swift in Sources */, C1A55E022E9F000000B10002 /* IssueReportContext.swift in Sources */, C1A55E022E9F000000B10004 /* IssueReportSubmitter.swift in Sources */, + C1A55E022E9F000000B1000A /* DiagnosticReportView.swift in Sources */, C1A55E022E9F000000B10006 /* ReportProblemView.swift in Sources */, C1A55E022E9F000000B10008 /* SendIssueReportView.swift in Sources */, A9CE7D8425BC9B3700C04F9B /* LocalizedString.swift in Sources */, diff --git a/LoopSupportKitUI/IssueReport/DiagnosticReportView.swift b/LoopSupportKitUI/IssueReport/DiagnosticReportView.swift new file mode 100644 index 0000000..705cf97 --- /dev/null +++ b/LoopSupportKitUI/IssueReport/DiagnosticReportView.swift @@ -0,0 +1,100 @@ +// +// DiagnosticReportView.swift +// LoopSupportKitUI +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import SwiftUI + +/// Shows the diagnostic report as text, with a button to share it as a file. +struct DiagnosticReportView: View { + let generateReport: () async -> String + + @State private var report: String? + @State private var reportFile: ReportFile? + + private struct ReportFile: Identifiable { + let url: URL + var id: URL { url } + } + + var body: some View { + Group { + if let report { + ReportTextView(text: report) + .ignoresSafeArea(edges: .bottom) + } else { + ProgressView(LocalizedString("Preparing Report…", comment: "Shown while the diagnostic report is generated")) + } + } + .navigationTitle(LocalizedString("Diagnostic Report", comment: "Title of the diagnostic report viewer")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button(action: share) { + Image(systemName: "square.and.arrow.up") + } + .accessibilityLabel(LocalizedString("Share", comment: "Button to share the diagnostic report")) + .disabled(report == nil) + } + } + .sheet(item: $reportFile, onDismiss: removeReportFile) { file in + ActivityView(activityItems: [file.url]) + } + .task { + if report == nil { + report = "Generated: \(Date())\n\n" + (await generateReport()) + } + } + } + + private func share() { + guard let report else { + return + } + let name = "Loop Report \(ISO8601DateFormatter.string(from: Date(), timeZone: .current, formatOptions: [.withSpaceBetweenDateAndTime, .withInternetDateTime])).md" + let url = FileManager.default.temporaryDirectory.appendingPathComponent(name.replacingOccurrences(of: ":", with: "-")) + if (try? report.write(to: url, atomically: true, encoding: .utf8)) != nil { + reportFile = ReportFile(url: url) + } + } + + private func removeReportFile() { + if let url = reportFile?.url { + try? FileManager.default.removeItem(at: url) + } + } +} + +// A UITextView keeps scrolling smooth for reports of several hundred kilobytes +private struct ReportTextView: UIViewRepresentable { + let text: String + + func makeUIView(context: Context) -> UITextView { + let textView = UITextView() + textView.isEditable = false + textView.dataDetectorTypes = [] + textView.adjustsFontForContentSizeCategory = true + textView.font = UIFontMetrics(forTextStyle: .footnote).scaledFont(for: .monospacedSystemFont(ofSize: 12, weight: .regular)) + textView.textContainerInset = UIEdgeInsets(top: 12, left: 8, bottom: 12, right: 8) + textView.text = text + return textView + } + + func updateUIView(_ textView: UITextView, context: Context) { + if textView.text != text { + textView.text = text + } + } +} + +private struct ActivityView: UIViewControllerRepresentable { + let activityItems: [Any] + + func makeUIViewController(context: Context) -> UIActivityViewController { + UIActivityViewController(activityItems: activityItems, applicationActivities: nil) + } + + func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} +} diff --git a/LoopSupportKitUI/IssueReport/ReportProblemView.swift b/LoopSupportKitUI/IssueReport/ReportProblemView.swift index dba6e91..5ad569c 100644 --- a/LoopSupportKitUI/IssueReport/ReportProblemView.swift +++ b/LoopSupportKitUI/IssueReport/ReportProblemView.swift @@ -11,15 +11,6 @@ import LoopKitUI struct ReportProblemView: View { let delegate: SupportUIDelegate? - @State private var isPreparingReport = false - @State private var reportFile: ReportFile? - @State private var writtenReportURL: URL? - - private struct ReportFile: Identifiable { - let url: URL - var id: URL { url } - } - var body: some View { List { Section { @@ -33,20 +24,13 @@ struct ReportProblemView: View { ) } - if delegate != nil { - Button(action: shareDiagnosticReport) { - HStack { - option( - LocalizedString("Share a Diagnostic Report", comment: "Report a problem option: share the diagnostic report yourself"), - detail: LocalizedString("Save the report or send it to someone helping you, for example on Zulip or Facebook. It contains health data such as glucose, insulin, carbs and your therapy settings.", comment: "Report a problem option detail: share the diagnostic report yourself") - ) - if isPreparingReport { - Spacer() - ProgressView() - } - } + if let delegate { + NavigationLink(destination: DiagnosticReportView(generateReport: { await delegate.generateIssueReport() })) { + option( + LocalizedString("Share a Diagnostic Report", comment: "Report a problem option: share the diagnostic report yourself"), + detail: LocalizedString("View the report, then save it or send it to someone helping you, for example on Zulip or Facebook. It contains health data such as glucose, insulin, carbs and your therapy settings.", comment: "Report a problem option detail: share the diagnostic report yourself") + ) } - .disabled(isPreparingReport) } Button(action: { delegate?.openURL(url: URL(string: "https://github.com/LoopKit/Loop/issues")!) }) { @@ -69,9 +53,6 @@ struct ReportProblemView: View { .listStyle(.insetGrouped) .navigationTitle(LocalizedString("Report a Problem", comment: "Title of the report a problem screen")) .navigationBarTitleDisplayMode(.inline) - .sheet(item: $reportFile, onDismiss: removeReportFile) { file in - ActivityView(activityItems: [file.url]) - } } private func option(_ title: String, detail: String) -> some View { @@ -84,34 +65,6 @@ struct ReportProblemView: View { } .padding(.vertical, 4) } - - private func shareDiagnosticReport() { - guard let delegate else { - return - } - isPreparingReport = true - Task { @MainActor in - let date = Date() - let report = "Generated: \(date)\n\n" + (await delegate.generateIssueReport()) - let name = "Loop Report \(ISO8601DateFormatter.string(from: date, timeZone: .current, formatOptions: [.withSpaceBetweenDateAndTime, .withInternetDateTime])).md" - let url = FileManager.default.temporaryDirectory.appendingPathComponent(name.replacingOccurrences(of: ":", with: "-")) - do { - try report.write(to: url, atomically: true, encoding: .utf8) - writtenReportURL = url - reportFile = ReportFile(url: url) - } catch { - reportFile = nil - } - isPreparingReport = false - } - } - - private func removeReportFile() { - if let url = writtenReportURL { - try? FileManager.default.removeItem(at: url) - } - writtenReportURL = nil - } } struct ReportProblemMenuItem: View { @@ -141,13 +94,3 @@ struct ReportProblemModal: View { } } } - -private struct ActivityView: UIViewControllerRepresentable { - let activityItems: [Any] - - func makeUIViewController(context: Context) -> UIActivityViewController { - UIActivityViewController(activityItems: activityItems, applicationActivities: nil) - } - - func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} -} diff --git a/LoopSupportKitUI/IssueReport/SendIssueReportView.swift b/LoopSupportKitUI/IssueReport/SendIssueReportView.swift index 9c32a6f..80fa484 100644 --- a/LoopSupportKitUI/IssueReport/SendIssueReportView.swift +++ b/LoopSupportKitUI/IssueReport/SendIssueReportView.swift @@ -74,9 +74,14 @@ struct SendIssueReportView: View { } } - if generateDiagnosticReport != nil { + if let generateDiagnosticReport { Section(footer: Text(LocalizedString("Attaches Loop's issue report as a text file. It contains health data such as recent glucose, insulin, carbs and your therapy settings.", comment: "Footer for the include diagnostic report toggle"))) { Toggle(LocalizedString("Include Diagnostic Report", comment: "Toggle for attaching the diagnostic report to an issue report"), isOn: $includeDiagnosticReport) + if includeDiagnosticReport { + NavigationLink(destination: DiagnosticReportView(generateReport: generateDiagnosticReport)) { + Text(LocalizedString("View Diagnostic Report", comment: "Button to view the diagnostic report attached to an issue report")) + } + } } }