diff --git a/LoopSupport.xcodeproj/project.pbxproj b/LoopSupport.xcodeproj/project.pbxproj index 728533e..8792a70 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 bf30c93..6de93be 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 { @@ -142,16 +95,6 @@ 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) {} -} - private extension SupportInfoProvider { func generateIssueReport() async -> String { await withCheckedContinuation { continuation in 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")) + } + } } } diff --git a/LoopSupportKitUI/Localizable.xcstrings b/LoopSupportKitUI/Localizable.xcstrings index 3ca37b4..2d57bb2 100644 --- a/LoopSupportKitUI/Localizable.xcstrings +++ b/LoopSupportKitUI/Localizable.xcstrings @@ -227,6 +227,9 @@ "Detailed logs as files. Only needed when a developer asks for them." : { "comment" : "Report a problem option detail: export the critical event logs" }, + "Diagnostic Report" : { + "comment" : "Title of the diagnostic report viewer" + }, "Do not share any data." : { "comment" : "Description in UsageDataPrivacyPreferenceView for no sharing", "localizations" : { @@ -565,6 +568,9 @@ "Open a GitHub Issue" : { "comment" : "Report a problem option: open the public GitHub issues page" }, + "Preparing Report…" : { + "comment" : "Shown while the diagnostic report is generated" + }, "Private. Describe the problem and attach screenshots and a diagnostic report. Only the LoopKit development team can see it." : { "comment" : "Report a problem option detail: submit an issue report to the developers" }, @@ -580,15 +586,15 @@ "Reports go to the LoopKit development team and are visible only to them. Your IP address is used only to limit how often reports can be sent and is never stored." : { "comment" : "Privacy footer for issue reports" }, - "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" - }, "Send to Developers" : { "comment" : "Title of the issue report screen" }, "Send to the Loop Developers" : { "comment" : "Report a problem option: submit an issue report to the developers" }, + "Share" : { + "comment" : "Button to share the diagnostic report" + }, "Share a Diagnostic Report" : { "comment" : "Report a problem option: share the diagnostic report yourself" }, @@ -1026,6 +1032,12 @@ } } }, + "View Diagnostic Report" : { + "comment" : "Button to view the diagnostic report attached to an issue report" + }, + "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" + }, "You can optionally choose to share usage data with Loop developers to improve Loop. Sharing is not required, and Loop will function fully no matter which option you choose. Usage data will not be shared with third parties." : { "comment" : "Main summary text for UsageDataPrivacyPreferenceView", "localizations" : {