From 99a2880306a9abd5c90f8a22ca7288b26046bf17 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Mon, 19 Oct 2026 10:06:22 -0500 Subject: [PATCH 1/2] Gather the ways to report a problem on one screen Support had Report Issue, Submit Bug Report (added by LoopSupport) and Export Critical Event Logs side by side, with local export hidden at the bottom of the developer report form. People couldn't tell which to use. Support now has one Report a Problem row. It opens a screen listing the four options, each saying who gets the report: the developers privately, someone helping you, GitHub publicly, or critical event logs on request. Sharing a diagnostic report now generates it in place and opens the share sheet, instead of closing Settings and showing the old text view. The canceled automatic bolus alert opens the same screen. --- Loop.xcodeproj/project.pbxproj | 8 +- .../CommandResponseViewController.swift | 36 ----- .../StatusTableViewController.swift | 15 +- Loop/View Models/SettingsViewModel.swift | 5 - Loop/Views/CriticalEventLogExportView.swift | 2 +- Loop/Views/ReportProblemView.swift | 136 ++++++++++++++++++ Loop/Views/SendIssueReportView.swift | 7 +- Loop/Views/SettingsView.swift | 12 +- 8 files changed, 150 insertions(+), 71 deletions(-) delete mode 100644 Loop/View Controllers/CommandResponseViewController.swift create mode 100644 Loop/Views/ReportProblemView.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 4a4bdb4d05..4c786ccf91 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -123,7 +123,6 @@ 4328E0351CFC0AE100E199AA /* WatchDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4328E0341CFC0AE100E199AA /* WatchDataManager.swift */; }; 432CF87520D8AC950066B889 /* NSUserDefaults+WatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4328E0231CFBE2C500E199AA /* NSUserDefaults+WatchApp.swift */; }; 432E73CB1D24B3D6009AD15D /* RemoteDataServicesManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 432E73CA1D24B3D6009AD15D /* RemoteDataServicesManager.swift */; }; - 433EA4C41D9F71C800CD78FB /* CommandResponseViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 433EA4C31D9F71C800CD78FB /* CommandResponseViewController.swift */; }; 4344628220A7A37F00C4BE6F /* CoreBluetooth.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4344628120A7A37E00C4BE6F /* CoreBluetooth.framework */; }; 4344629220A7C19800C4BE6F /* ButtonGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4344629120A7C19800C4BE6F /* ButtonGroup.swift */; }; 4344629820A8B2D700C4BE6F /* OSLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4374B5EE209D84BE00D17AA8 /* OSLog.swift */; }; @@ -359,6 +358,7 @@ A999D40624663D18004C89D4 /* PumpManagerError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A999D40524663D18004C89D4 /* PumpManagerError.swift */; }; A9A056B324B93C62007CF06D /* CriticalEventLogExportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9A056B224B93C62007CF06D /* CriticalEventLogExportView.swift */; }; C1A55E012E9F000000A10004 /* SendIssueReportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A55E012E9F000000A10003 /* SendIssueReportView.swift */; }; + C1A55E012E9F000000A10006 /* ReportProblemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A55E012E9F000000A10005 /* ReportProblemView.swift */; }; A9A056B524B94123007CF06D /* CriticalEventLogExportViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9A056B424B94123007CF06D /* CriticalEventLogExportViewModel.swift */; }; A9A63F8E246B271600588D5B /* NSTimeInterval.swift in Sources */ = {isa = PBXBuildFile; fileRef = 439897341CD2F7DE00223065 /* NSTimeInterval.swift */; }; A9B607B0247F000F00792BE4 /* UserNotifications+Loop.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9B607AF247F000F00792BE4 /* UserNotifications+Loop.swift */; }; @@ -891,7 +891,6 @@ 4328E0341CFC0AE100E199AA /* WatchDataManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = WatchDataManager.swift; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; 432E73CA1D24B3D6009AD15D /* RemoteDataServicesManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteDataServicesManager.swift; sourceTree = ""; }; 4337615E1D52F487004A3647 /* GlucoseHUDView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GlucoseHUDView.swift; sourceTree = ""; }; - 433EA4C31D9F71C800CD78FB /* CommandResponseViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CommandResponseViewController.swift; sourceTree = ""; }; 4344628120A7A37E00C4BE6F /* CoreBluetooth.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreBluetooth.framework; path = Platforms/WatchOS.platform/Developer/SDKs/WatchOS.sdk/System/Library/Frameworks/CoreBluetooth.framework; sourceTree = DEVELOPER_DIR; }; 4344628320A7A3BE00C4BE6F /* LoopKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = LoopKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 4344628420A7A3BE00C4BE6F /* CGMBLEKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = CGMBLEKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -1174,6 +1173,7 @@ A999D40524663D18004C89D4 /* PumpManagerError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpManagerError.swift; sourceTree = ""; }; A9A056B224B93C62007CF06D /* CriticalEventLogExportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CriticalEventLogExportView.swift; sourceTree = ""; }; C1A55E012E9F000000A10003 /* SendIssueReportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendIssueReportView.swift; sourceTree = ""; }; + C1A55E012E9F000000A10005 /* ReportProblemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportProblemView.swift; sourceTree = ""; }; A9A056B424B94123007CF06D /* CriticalEventLogExportViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CriticalEventLogExportViewModel.swift; sourceTree = ""; }; A9B607AF247F000F00792BE4 /* UserNotifications+Loop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserNotifications+Loop.swift"; sourceTree = ""; }; A9B996EF27235191002DC09C /* LoopWarning.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopWarning.swift; sourceTree = ""; }; @@ -2007,7 +2007,6 @@ children = ( 43A51E1E1EB6D62A000736CC /* CarbAbsorptionViewController.swift */, 43A51E201EB6DBDD000736CC /* LoopChartsTableViewController.swift */, - 433EA4C31D9F71C800CD78FB /* CommandResponseViewController.swift */, C178249F1E19CF9800D9D25C /* GlucoseThresholdTableViewController.swift */, 4302F4E21D4EA54200F0FCAF /* InsulinDeliveryTableViewController.swift */, 437D9BA21D7BC977007245E8 /* PredictionTableViewController.swift */, @@ -2035,6 +2034,7 @@ 431A8C3F1EC6E8AB00823B9C /* CircleMaskView.swift */, A9A056B224B93C62007CF06D /* CriticalEventLogExportView.swift */, C1A55E012E9F000000A10003 /* SendIssueReportView.swift */, + C1A55E012E9F000000A10005 /* ReportProblemView.swift */, C191D2A025B3ACAA00C26C0B /* DosingStrategySelectionView.swift */, 14BBB3AE2C61274400ECB800 /* Favorite Foods */, 43D381611EBD9759007F8C8F /* HeaderValuesTableViewCell.swift */, @@ -3414,6 +3414,7 @@ 4F70C2131DE90339006380B7 /* StatusExtensionContext.swift in Sources */, A9A056B324B93C62007CF06D /* CriticalEventLogExportView.swift in Sources */, C1A55E012E9F000000A10004 /* SendIssueReportView.swift in Sources */, + C1A55E012E9F000000A10006 /* ReportProblemView.swift in Sources */, 43C05CC521EC29E3006FB252 /* TextFieldTableViewCell.swift in Sources */, C1D289B522F90A52003FFBD9 /* BasalDeliveryState.swift in Sources */, 4F2C15821E074FC600E160D4 /* NSTimeInterval.swift in Sources */, @@ -3611,7 +3612,6 @@ A987CD4924A58A0100439ADC /* ZipArchive.swift in Sources */, 43F41C371D3BF32400C11ED6 /* UIAlertController.swift in Sources */, A9CBE45C248ACC03008E7BA2 /* SettingsStore+SimulatedCoreData.swift in Sources */, - 433EA4C41D9F71C800CD78FB /* CommandResponseViewController.swift in Sources */, E9B3552229358C440076AB04 /* MealDetectionManager.swift in Sources */, C16DA84222E8E112008624C2 /* PluginManager.swift in Sources */, C151634E2FA2C9C800FEECE8 /* RequiredVersionUpdateView.swift in Sources */, diff --git a/Loop/View Controllers/CommandResponseViewController.swift b/Loop/View Controllers/CommandResponseViewController.swift deleted file mode 100644 index 2bd93cc09f..0000000000 --- a/Loop/View Controllers/CommandResponseViewController.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// CommandResponseViewController.swift -// Loop -// -// Created by Nate Racklyeft on 9/30/16. -// Copyright © 2016 LoopKit Authors. All rights reserved. -// - -import Foundation -import LoopKitUI - - -extension CommandResponseViewController { - typealias T = CommandResponseViewController - - static func generateDiagnosticReport(reportGenerator: DiagnosticReportGenerator) -> T { - let date = Date() - let vc = T(command: { (completionHandler) in - Task { @MainActor in - let report = await reportGenerator.generateDiagnosticReport() - // TODO: https://tidepool.atlassian.net/browse/LOOP-4771 - completionHandler([ - "Use the Share button above to save this diagnostic report to aid investigating your problem. Issues can be filed at https://github.com/LoopKit/Loop/issues.", - "Generated: \(date)", - "", - report, - "", - ].joined(separator: "\n\n")) - } - return NSLocalizedString("Loading...", comment: "The loading message for the diagnostic report screen") - }) - vc.fileName = "Loop Report \(ISO8601DateFormatter.string(from: date, timeZone: .current, formatOptions: [.withSpaceBetweenDateAndTime, .withInternetDateTime])).md" - - return vc - } -} diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 884e4d54e4..2d51d3c668 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -1469,9 +1469,10 @@ final class StatusTableViewController: LoopChartsTableViewController { } private func presentIssueReport() { - let vc = CommandResponseViewController.generateDiagnosticReport(reportGenerator: diagnosticReportGenerator) - vc.title = NSLocalizedString("Issue Report", comment: "The view controller title for the issue report screen") - show(vc, sender: nil) + let hostingController = DismissibleHostingController( + rootView: ReportProblemModal(viewModel: statusTableViewModel.settingsViewModel), + isModalInPresentation: false) + present(hostingController, animated: true) } // MARK: - Actions @@ -2266,14 +2267,6 @@ extension StatusTableViewController: SettingsViewModelDelegate { } } - func didTapIssueReport() { - // TODO: this dismiss here is temporary, until we know exactly where - // we want this screen to belong in the navigation flow - dismiss(animated: true) { - self.presentIssueReport() - } - } - func generateDiagnosticReport() async -> String { await diagnosticReportGenerator.generateDiagnosticReport() } diff --git a/Loop/View Models/SettingsViewModel.swift b/Loop/View Models/SettingsViewModel.swift index a1573bbde7..b8be652a04 100644 --- a/Loop/View Models/SettingsViewModel.swift +++ b/Loop/View Models/SettingsViewModel.swift @@ -52,7 +52,6 @@ public typealias PumpManagerViewModel = DeviceViewModel public protocol SettingsViewModelDelegate: AnyObject { func dosingEnabledChanged(_: Bool) func dosingStrategyChanged(_: AutomaticDosingStrategy) - func didTapIssueReport() func generateDiagnosticReport() async -> String var closedLoopDescriptiveText: String? { get } var automaticDosingEnabled: Bool { get set } @@ -72,10 +71,6 @@ class SettingsViewModel { weak var deliveryDelegate: DeliveryDelegate? - func didTapIssueReport() { - delegate?.didTapIssueReport() - } - var generateDiagnosticReport: (() async -> String)? { guard let delegate else { return nil diff --git a/Loop/Views/CriticalEventLogExportView.swift b/Loop/Views/CriticalEventLogExportView.swift index e61eb93ee3..9976c7605f 100644 --- a/Loop/Views/CriticalEventLogExportView.swift +++ b/Loop/Views/CriticalEventLogExportView.swift @@ -94,7 +94,7 @@ struct CriticalEventLogExportView: View { } } -fileprivate struct ActivityViewController: UIViewControllerRepresentable { +struct ActivityViewController: UIViewControllerRepresentable { var activityItems: [Any] var applicationActivities: [UIActivity]? = nil diff --git a/Loop/Views/ReportProblemView.swift b/Loop/Views/ReportProblemView.swift new file mode 100644 index 0000000000..23ecc7427c --- /dev/null +++ b/Loop/Views/ReportProblemView.swift @@ -0,0 +1,136 @@ +// +// ReportProblemView.swift +// Loop +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import LoopKitUI +import SwiftUI + +/// Report a Problem shown on its own, outside Settings. +struct ReportProblemModal: View { + let viewModel: SettingsViewModel + + @Environment(\.dismissAction) private var dismiss + + var body: some View { + NavigationView { + ReportProblemView( + issueReportContext: viewModel.issueReportContext, + generateDiagnosticReport: viewModel.generateDiagnosticReport, + criticalEventLogExportViewModel: viewModel.criticalEventLogExportViewModel + ) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button(NSLocalizedString("Done", comment: "Button to close the report a problem screen"), action: dismiss) + } + } + } + } +} + +struct ReportProblemView: View { + let issueReportContext: [String: IssueReportContextValue] + let generateDiagnosticReport: (() async -> String)? + let criticalEventLogExportViewModel: CriticalEventLogExportViewModel + + @Environment(\.openURL) private var openURL + + @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 { + NavigationLink(destination: SendIssueReportView( + context: issueReportContext, + generateDiagnosticReport: generateDiagnosticReport + )) { + option( + NSLocalizedString("Send to the Loop Developers", comment: "Report a problem option: submit an issue report to the developers"), + detail: NSLocalizedString("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") + ) + } + + if let generateDiagnosticReport { + Button(action: { shareDiagnosticReport(generateDiagnosticReport) }) { + HStack { + option( + NSLocalizedString("Share a Diagnostic Report", comment: "Report a problem option: share the diagnostic report yourself"), + detail: NSLocalizedString("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() + } + } + } + .disabled(isPreparingReport) + } + + Button(action: { openURL(URL(string: "https://github.com/LoopKit/Loop/issues")!) }) { + option( + NSLocalizedString("Open a GitHub Issue", comment: "Report a problem option: open the public GitHub issues page"), + detail: NSLocalizedString("Public. For bugs you can describe without any personal or health data.", comment: "Report a problem option detail: open the public GitHub issues page") + ) + } + + NavigationLink(destination: CriticalEventLogExportView(viewModel: criticalEventLogExportViewModel)) { + option( + NSLocalizedString("Export Critical Event Logs", comment: "Report a problem option: export the critical event logs"), + detail: NSLocalizedString("Detailed logs as files. Only needed when a developer asks for them.", comment: "Report a problem option detail: export the critical event logs") + ) + } + } + } + .listStyle(.insetGrouped) + .navigationTitle(NSLocalizedString("Report a Problem", comment: "Title of the report a problem screen")) + .navigationBarTitleDisplayMode(.inline) + .sheet(item: $reportFile, onDismiss: removeReportFile) { file in + ActivityViewController(activityItems: [file.url]) + } + } + + private func option(_ title: String, detail: String) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .foregroundColor(.primary) + Text(detail) + .font(.footnote) + .foregroundColor(.secondary) + } + .padding(.vertical, 4) + } + + private func shareDiagnosticReport(_ generate: @escaping () async -> String) { + isPreparingReport = true + Task { @MainActor in + let date = Date() + let report = "Generated: \(date)\n\n" + (await generate()) + 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 + } +} diff --git a/Loop/Views/SendIssueReportView.swift b/Loop/Views/SendIssueReportView.swift index 5eb2ca73ee..288e1d0de2 100644 --- a/Loop/Views/SendIssueReportView.swift +++ b/Loop/Views/SendIssueReportView.swift @@ -11,7 +11,6 @@ import SwiftUI struct SendIssueReportView: View { let context: [String: IssueReportContextValue] let generateDiagnosticReport: (() async -> String)? - let exportLocally: () -> Void @Environment(\.dismiss) private var dismiss @@ -95,12 +94,8 @@ struct SendIssueReportView: View { .font(.footnote) } } - - Section(footer: Text(NSLocalizedString("Opens Loop's diagnostic report so you can save it or share it yourself. Nothing is sent to the developers.", comment: "Footer for exporting the issue report locally"))) { - Button(NSLocalizedString("Export Locally Instead", comment: "Button to open the diagnostic report for local export instead of submitting"), action: exportLocally) - } } - .navigationTitle(NSLocalizedString("Report Issue", comment: "Title of the issue report screen")) + .navigationTitle(NSLocalizedString("Send to Developers", comment: "Title of the issue report screen")) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .confirmationAction) { diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index b4d99857a2..9a3515ed61 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -600,22 +600,18 @@ extension SettingsView { private var supportSection: some View { Section(header: SectionHeader(label: NSLocalizedString("Support", comment: "The title of the support section in settings"))) { - NavigationLink(destination: SendIssueReportView( - context: viewModel.issueReportContext, + NavigationLink(destination: ReportProblemView( + issueReportContext: viewModel.issueReportContext, generateDiagnosticReport: viewModel.generateDiagnosticReport, - exportLocally: viewModel.didTapIssueReport + criticalEventLogExportViewModel: viewModel.criticalEventLogExportViewModel )) { - Text(NSLocalizedString("Report Issue", comment: "The title text for the issue report menu item")) + Text(NSLocalizedString("Report a Problem", comment: "The title text for the report a problem menu item")) } ForEach(pluginMenuItems.filter( { $0.section == .support })) { $0.view } - NavigationLink(destination: CriticalEventLogExportView(viewModel: viewModel.criticalEventLogExportViewModel)) { - Text(NSLocalizedString("Export Critical Event Logs", comment: "The title of the export critical event logs in support")) - } - supportInfoRow(NSLocalizedString("Version", comment: "Label for the app version row in the Support section"), value: Text(localizedAppNameAndVersion)) if let branch = BuildDetails.default.workspaceGitBranch ?? BuildDetails.default.gitBranch { From 26385809a10b0dc97b475e5415a0c03531c795ee Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Mon, 19 Oct 2026 11:13:38 -0500 Subject: [PATCH 2/2] Leave the Report a Problem options to support plugins Support now shows only what support plugins add, plus the build info. The report screen, developer report form and submitter move to LoopSupport, since Tidepool Loop will offer different options. Loop provides the critical event log export through the support delegate, and generateIssueReport now returns the full diagnostic report rather than only the device section. The canceled automatic bolus alert opens the first support plugin's Report a Problem screen, or shares a diagnostic report directly if none provides one. --- Loop.xcodeproj/project.pbxproj | 12 - Loop/Managers/IssueReportSubmitter.swift | 139 ---------- Loop/Managers/LoopAppManager.swift | 5 +- Loop/Managers/SupportManager.swift | 24 +- .../StatusTableViewController.swift | 27 +- Loop/View Models/SettingsViewModel.swift | 33 +-- Loop/Views/CriticalEventLogExportView.swift | 2 +- Loop/Views/ReportProblemView.swift | 136 ---------- Loop/Views/SendIssueReportView.swift | 249 ------------------ Loop/Views/SettingsView.swift | 8 - 10 files changed, 44 insertions(+), 591 deletions(-) delete mode 100644 Loop/Managers/IssueReportSubmitter.swift delete mode 100644 Loop/Views/ReportProblemView.swift delete mode 100644 Loop/Views/SendIssueReportView.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 4c786ccf91..0095528757 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -72,7 +72,6 @@ 1D4A3E2D2478628500FD601B /* StoredAlert+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D4A3E2B2478628500FD601B /* StoredAlert+CoreDataClass.swift */; }; 1D4A3E2E2478628500FD601B /* StoredAlert+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D4A3E2C2478628500FD601B /* StoredAlert+CoreDataProperties.swift */; }; 1D63DEA526E950D400F46FA5 /* SupportManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D63DEA426E950D400F46FA5 /* SupportManager.swift */; }; - C1A55E012E9F000000A10002 /* IssueReportSubmitter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A55E012E9F000000A10001 /* IssueReportSubmitter.swift */; }; 1D6B1B6726866D89009AC446 /* AlertPermissionsChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D6B1B6626866D89009AC446 /* AlertPermissionsChecker.swift */; }; 1D70C40126EC0F9D00C62570 /* SupportManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D70C40026EC0F9D00C62570 /* SupportManagerTests.swift */; }; 1D80313D24746274002810DF /* AlertStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D80313C24746274002810DF /* AlertStoreTests.swift */; }; @@ -357,8 +356,6 @@ A987CD4924A58A0100439ADC /* ZipArchive.swift in Sources */ = {isa = PBXBuildFile; fileRef = A987CD4824A58A0100439ADC /* ZipArchive.swift */; }; A999D40624663D18004C89D4 /* PumpManagerError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A999D40524663D18004C89D4 /* PumpManagerError.swift */; }; A9A056B324B93C62007CF06D /* CriticalEventLogExportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9A056B224B93C62007CF06D /* CriticalEventLogExportView.swift */; }; - C1A55E012E9F000000A10004 /* SendIssueReportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A55E012E9F000000A10003 /* SendIssueReportView.swift */; }; - C1A55E012E9F000000A10006 /* ReportProblemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A55E012E9F000000A10005 /* ReportProblemView.swift */; }; A9A056B524B94123007CF06D /* CriticalEventLogExportViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9A056B424B94123007CF06D /* CriticalEventLogExportViewModel.swift */; }; A9A63F8E246B271600588D5B /* NSTimeInterval.swift in Sources */ = {isa = PBXBuildFile; fileRef = 439897341CD2F7DE00223065 /* NSTimeInterval.swift */; }; A9B607B0247F000F00792BE4 /* UserNotifications+Loop.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9B607AF247F000F00792BE4 /* UserNotifications+Loop.swift */; }; @@ -838,7 +835,6 @@ 1D4A3E2B2478628500FD601B /* StoredAlert+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StoredAlert+CoreDataClass.swift"; sourceTree = ""; }; 1D4A3E2C2478628500FD601B /* StoredAlert+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StoredAlert+CoreDataProperties.swift"; sourceTree = ""; }; 1D63DEA426E950D400F46FA5 /* SupportManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportManager.swift; sourceTree = ""; }; - C1A55E012E9F000000A10001 /* IssueReportSubmitter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IssueReportSubmitter.swift; sourceTree = ""; }; 1D6B1B6626866D89009AC446 /* AlertPermissionsChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertPermissionsChecker.swift; sourceTree = ""; }; 1D70C40026EC0F9D00C62570 /* SupportManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportManagerTests.swift; sourceTree = ""; }; 1D80313C24746274002810DF /* AlertStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertStoreTests.swift; sourceTree = ""; }; @@ -1172,8 +1168,6 @@ A987CD4824A58A0100439ADC /* ZipArchive.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZipArchive.swift; sourceTree = ""; }; A999D40524663D18004C89D4 /* PumpManagerError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpManagerError.swift; sourceTree = ""; }; A9A056B224B93C62007CF06D /* CriticalEventLogExportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CriticalEventLogExportView.swift; sourceTree = ""; }; - C1A55E012E9F000000A10003 /* SendIssueReportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendIssueReportView.swift; sourceTree = ""; }; - C1A55E012E9F000000A10005 /* ReportProblemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportProblemView.swift; sourceTree = ""; }; A9A056B424B94123007CF06D /* CriticalEventLogExportViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CriticalEventLogExportViewModel.swift; sourceTree = ""; }; A9B607AF247F000F00792BE4 /* UserNotifications+Loop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserNotifications+Loop.swift"; sourceTree = ""; }; A9B996EF27235191002DC09C /* LoopWarning.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopWarning.swift; sourceTree = ""; }; @@ -2033,8 +2027,6 @@ 14C970662C59918100E8A01B /* Charts */, 431A8C3F1EC6E8AB00823B9C /* CircleMaskView.swift */, A9A056B224B93C62007CF06D /* CriticalEventLogExportView.swift */, - C1A55E012E9F000000A10003 /* SendIssueReportView.swift */, - C1A55E012E9F000000A10005 /* ReportProblemView.swift */, C191D2A025B3ACAA00C26C0B /* DosingStrategySelectionView.swift */, 14BBB3AE2C61274400ECB800 /* Favorite Foods */, 43D381611EBD9759007F8C8F /* HeaderValuesTableViewCell.swift */, @@ -2111,7 +2103,6 @@ 43FCEEA8221A615B0013DD30 /* StatusChartsManager.swift */, E95D37FF24EADE68005E2F50 /* Store Protocols */, 1D63DEA426E950D400F46FA5 /* SupportManager.swift */, - C1A55E012E9F000000A10001 /* IssueReportSubmitter.swift */, C18859AB2AF29BE50010F21F /* TemporaryPresetsManager.swift */, 89ADE13A226BFA0F0067222B /* TestingScenariosManager.swift */, 1D82E69F25377C6B009131FB /* TrustedTimeChecker.swift */, @@ -3413,8 +3404,6 @@ 897A5A9624C2175B00C4E71D /* BolusEntryView.swift in Sources */, 4F70C2131DE90339006380B7 /* StatusExtensionContext.swift in Sources */, A9A056B324B93C62007CF06D /* CriticalEventLogExportView.swift in Sources */, - C1A55E012E9F000000A10004 /* SendIssueReportView.swift in Sources */, - C1A55E012E9F000000A10006 /* ReportProblemView.swift in Sources */, 43C05CC521EC29E3006FB252 /* TextFieldTableViewCell.swift in Sources */, C1D289B522F90A52003FFBD9 /* BasalDeliveryState.swift in Sources */, 4F2C15821E074FC600E160D4 /* NSTimeInterval.swift in Sources */, @@ -3576,7 +3565,6 @@ A96DAC2C2838F31200D94E38 /* SharedLogging.swift in Sources */, 4302F4E31D4EA54200F0FCAF /* InsulinDeliveryTableViewController.swift in Sources */, 1D63DEA526E950D400F46FA5 /* SupportManager.swift in Sources */, - C1A55E012E9F000000A10002 /* IssueReportSubmitter.swift in Sources */, 84D1F1A72D09053A00CB271F /* StatusTableView.swift in Sources */, 4FC8C8011DEB93E400A1452E /* NSUserDefaults+StatusExtension.swift in Sources */, 14C9707E2C5A9EB600E8A01B /* GlucoseCarbChartView.swift in Sources */, diff --git a/Loop/Managers/IssueReportSubmitter.swift b/Loop/Managers/IssueReportSubmitter.swift deleted file mode 100644 index 68669dd2f3..0000000000 --- a/Loop/Managers/IssueReportSubmitter.swift +++ /dev/null @@ -1,139 +0,0 @@ -// -// IssueReportSubmitter.swift -// Loop -// -// Copyright © 2026 LoopKit Authors. All rights reserved. -// - -import Foundation - -struct IssueReport { - struct Contact: Encodable { - var name: String? - var email: String? - var zulip: String? - var github: String? - } - - struct Attachment { - var filename: String - var contentType: String - var data: Data - } - - var title: String? - var description: String - var contact: Contact? - var context: [String: IssueReportContextValue] - var attachments: [Attachment] -} - -enum IssueReportContextValue: Encodable, Equatable { - case string(String) - case strings([String]) - - func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - switch self { - case .string(let value): try container.encode(value) - case .strings(let value): try container.encode(value) - } - } -} - -enum IssueReportSubmitterError: LocalizedError { - case server(status: Int, reason: String?) - case invalidResponse - case network(Error) - - var isRetryable: Bool { - switch self { - case .server(let status, _): return status >= 500 - case .invalidResponse, .network: return true - } - } - - var errorDescription: String? { - switch self { - case .server(413, _): - return NSLocalizedString("The report is too large. Try fewer or smaller screenshots.", comment: "Issue report error when the upload is too large") - case .server(415, _): - return NSLocalizedString("One of the attachments isn't a supported image type.", comment: "Issue report error when an attachment type is refused") - case .server(429, _): - return NSLocalizedString("Too many reports have been sent from this network recently. Please try again later.", comment: "Issue report error when rate limited") - case .server(let status, let reason): - return String(format: NSLocalizedString("The server couldn't accept the report (%1$d %2$@).", comment: "Issue report error with HTTP status (1) and reason (2)"), status, reason ?? "") - case .invalidResponse: - return NSLocalizedString("The server sent an unexpected response.", comment: "Issue report error for an unreadable response") - case .network(let error): - return error.localizedDescription - } - } -} - -struct IssueReportSubmitter { - static let endpoint = URL(string: "https://ingest.loopkit.org/v1/issues")! - static let apiKey = "7dd7414785560c0dd1ef802ac10c00b4" - - var session: URLSession = .shared - - func submit(_ report: IssueReport) async throws -> String { - let boundary = "Boundary-\(UUID().uuidString)" - var request = URLRequest(url: Self.endpoint) - request.httpMethod = "POST" - request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") - - let body = try Self.body(for: report, boundary: boundary) - let data: Data - let response: URLResponse - do { - (data, response) = try await session.upload(for: request, from: body) - } catch { - throw IssueReportSubmitterError.network(error) - } - guard let response = response as? HTTPURLResponse else { - throw IssueReportSubmitterError.invalidResponse - } - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] - guard response.statusCode == 200 else { - throw IssueReportSubmitterError.server(status: response.statusCode, reason: json?["error"] as? String) - } - guard let id = json?["id"] as? String else { - throw IssueReportSubmitterError.invalidResponse - } - return id - } - - static func body(for report: IssueReport, boundary: String) throws -> Data { - struct Payload: Encodable { - var title: String? - var description: String - var contact: IssueReport.Contact? - var context: [String: IssueReportContextValue] - } - let payload = try JSONEncoder().encode(Payload(title: report.title, description: report.description, contact: report.contact, context: report.context)) - - var body = Data() - func appendPart(name: String, filename: String? = nil, contentType: String? = nil, data: Data) { - var header = "--\(boundary)\r\nContent-Disposition: form-data; name=\"\(name)\"" - if let filename { - header += "; filename=\"\(filename.replacingOccurrences(of: "\"", with: ""))\"" - } - header += "\r\n" - if let contentType { - header += "Content-Type: \(contentType)\r\n" - } - body.append(Data((header + "\r\n").utf8)) - body.append(data) - body.append(Data("\r\n".utf8)) - } - - appendPart(name: "client", data: Data(apiKey.utf8)) - appendPart(name: "report", contentType: "application/json", data: payload) - for attachment in report.attachments { - appendPart(name: "attachment", filename: attachment.filename, contentType: attachment.contentType, data: attachment.data) - } - body.append(Data("--\(boundary)--\r\n".utf8)) - return body - } -} diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index 3091e30489..64547964b0 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -447,7 +447,9 @@ class LoopAppManager: NSObject { supportManager = SupportManager(pluginManager: pluginManager, deviceSupportDelegate: deviceDataManager, servicesManager: servicesManager, - alertIssuer: alertManager) + alertIssuer: alertManager, + criticalEventLogExporterFactory: criticalEventLogExportManager, + diagnosticReportGenerator: self) servicesManager.supportManager = supportManager supportManager.onRequiredUpdate = { [weak self] in @@ -587,7 +589,6 @@ class LoopAppManager: NSObject { pumpManagerSettingsViewModel: pumpViewModel, cgmManagerSettingsViewModel: cgmViewModel, servicesViewModel: servicesViewModel, - criticalEventLogExportViewModel: CriticalEventLogExportViewModel(exporterFactory: criticalEventLogExportManager), therapySettings: { [weak self] in self?.settingsManager.therapySettings ?? TherapySettings() }, initialDosingEnabled: self.settingsManager.settings.dosingEnabled, automaticDosingStrategy: self.settingsManager.settings.automaticDosingStrategy, diff --git a/Loop/Managers/SupportManager.swift b/Loop/Managers/SupportManager.swift index 52273365a6..40ecec5cea 100644 --- a/Loop/Managers/SupportManager.swift +++ b/Loop/Managers/SupportManager.swift @@ -42,17 +42,23 @@ public final class SupportManager { private let alertIssuer: AlertIssuer private let deviceSupportDelegate: DeviceSupportDelegate private let pluginManager: PluginManager + private let criticalEventLogExporterFactory: CriticalEventLogExporterFactory? + private weak var diagnosticReportGenerator: DiagnosticReportGenerator? lazy private var cancellables = Set() init(pluginManager: PluginManager, deviceSupportDelegate: DeviceSupportDelegate, servicesManager: ServicesManager? = nil, - alertIssuer: AlertIssuer) { - + alertIssuer: AlertIssuer, + criticalEventLogExporterFactory: CriticalEventLogExporterFactory? = nil, + diagnosticReportGenerator: DiagnosticReportGenerator? = nil) { + self.alertIssuer = alertIssuer self.deviceSupportDelegate = deviceSupportDelegate self.pluginManager = pluginManager + self.criticalEventLogExporterFactory = criticalEventLogExporterFactory + self.diagnosticReportGenerator = diagnosticReportGenerator restoreState() @@ -216,7 +222,14 @@ extension SupportManager: SupportUIDelegate { public func openURL(url: URL) { UIApplication.shared.open(url) } - + + public func criticalEventLogExportView() -> AnyView { + guard let criticalEventLogExporterFactory else { + return AnyView(EmptyView()) + } + return AnyView(CriticalEventLogExportView(viewModel: CriticalEventLogExportViewModel(exporterFactory: criticalEventLogExporterFactory))) + } + public var pumpStatus: LoopKit.PumpManagerStatus? { deviceSupportDelegate.pumpManagerStatus } @@ -242,7 +255,10 @@ extension SupportManager: SupportUIDelegate { } public func generateIssueReport() async -> String { - await deviceSupportDelegate.generateDiagnosticReport() + if let diagnosticReportGenerator { + return await diagnosticReportGenerator.generateDiagnosticReport() + } + return await deviceSupportDelegate.generateDiagnosticReport() } public func issueAlert(_ alert: LoopKit.Alert) async { diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 2d51d3c668..6f31ab976e 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -1469,10 +1469,25 @@ final class StatusTableViewController: LoopChartsTableViewController { } private func presentIssueReport() { - let hostingController = DismissibleHostingController( - rootView: ReportProblemModal(viewModel: statusTableViewModel.settingsViewModel), - isModalInPresentation: false) - present(hostingController, animated: true) + if let reportProblemView = supportManager.availableSupports.lazy.compactMap({ $0.reportProblemView() }).first { + present(DismissibleHostingController(rootView: reportProblemView, isModalInPresentation: false), animated: true) + return + } + + Task { @MainActor in + let date = Date() + let report = "Generated: \(date)\n\n" + (await diagnosticReportGenerator.generateDiagnosticReport()) + let name = "Loop Report \(ISO8601DateFormatter.string(from: date, timeZone: .current, formatOptions: [.withSpaceBetweenDateAndTime, .withInternetDateTime])).md" + let url = FileManager.default.temporaryDirectory.appendingPathComponent(name.replacingOccurrences(of: ":", with: "-")) + guard (try? report.write(to: url, atomically: true, encoding: .utf8)) != nil else { + return + } + let activityViewController = UIActivityViewController(activityItems: [url], applicationActivities: nil) + activityViewController.completionWithItemsHandler = { _, _, _, _ in + try? FileManager.default.removeItem(at: url) + } + present(activityViewController, animated: true) + } } // MARK: - Actions @@ -2266,10 +2281,6 @@ extension StatusTableViewController: SettingsViewModelDelegate { settings.automaticDosingStrategy = strategy } } - - func generateDiagnosticReport() async -> String { - await diagnosticReportGenerator.generateDiagnosticReport() - } } // MARK: - Services delegation diff --git a/Loop/View Models/SettingsViewModel.swift b/Loop/View Models/SettingsViewModel.swift index b8be652a04..ac6a2cdbd9 100644 --- a/Loop/View Models/SettingsViewModel.swift +++ b/Loop/View Models/SettingsViewModel.swift @@ -52,7 +52,6 @@ public typealias PumpManagerViewModel = DeviceViewModel public protocol SettingsViewModelDelegate: AnyObject { func dosingEnabledChanged(_: Bool) func dosingStrategyChanged(_: AutomaticDosingStrategy) - func generateDiagnosticReport() async -> String var closedLoopDescriptiveText: String? { get } var automaticDosingEnabled: Bool { get set } var automationHistory: [AutomationHistoryEntry] { get } @@ -71,36 +70,10 @@ class SettingsViewModel { weak var deliveryDelegate: DeliveryDelegate? - var generateDiagnosticReport: (() async -> String)? { - guard let delegate else { - return nil - } - return { [weak delegate] in await delegate?.generateDiagnosticReport() ?? "" } - } - - @MainActor - var issueReportContext: [String: IssueReportContextValue] { - let buildDetails = BuildDetails.default - var context: [String: IssueReportContextValue] = [ - "appVersion": .string(Bundle.main.shortVersionString), - "buildNumber": .string(Bundle.main.version), - "customizations": .strings(buildDetails.customizations), - "deviceModel": .string(UIDevice.current.modelIdentifier), - "osVersion": .string(UIDevice.current.systemVersion), - ] - context["branch"] = (buildDetails.workspaceGitBranch ?? buildDetails.gitBranch).map { .string($0) } - context["workspaceRevision"] = buildDetails.workspaceGitRevision.map { .string($0) } - context["loopRevision"] = buildDetails.gitRevision.map { .string($0) } - context["pumpModel"] = deviceManager?.pumpManager.map { .string($0.status.device.model ?? "Unknown") } - context["cgmModel"] = deviceManager?.cgmManager.map { .string($0.cgmManagerStatus.device?.model ?? "Unknown") } - return context - } - var availableSupports: [SupportUI] let pumpManagerSettingsViewModel: PumpManagerViewModel let cgmManagerSettingsViewModel: CGMManagerViewModel let servicesViewModel: ServicesViewModel - let criticalEventLogExportViewModel: CriticalEventLogExportViewModel let therapySettings: () -> TherapySettings var isOnboardingComplete: Bool let therapySettingsViewModelDelegate: TherapySettingsViewModelDelegate? @@ -170,7 +143,6 @@ class SettingsViewModel { pumpManagerSettingsViewModel: PumpManagerViewModel, cgmManagerSettingsViewModel: CGMManagerViewModel, servicesViewModel: ServicesViewModel, - criticalEventLogExportViewModel: CriticalEventLogExportViewModel, therapySettings: @escaping () -> TherapySettings, initialDosingEnabled: Bool, automaticDosingStrategy: AutomaticDosingStrategy, @@ -190,7 +162,6 @@ class SettingsViewModel { self.pumpManagerSettingsViewModel = pumpManagerSettingsViewModel self.cgmManagerSettingsViewModel = cgmManagerSettingsViewModel self.servicesViewModel = servicesViewModel - self.criticalEventLogExportViewModel = criticalEventLogExportViewModel self.therapySettings = therapySettings self.closedLoopPreference = initialDosingEnabled self.automaticDosingStrategy = automaticDosingStrategy @@ -271,9 +242,7 @@ extension SettingsViewModel { versionUpdateViewModel: VersionUpdateViewModel(supportManager: nil, guidanceColors: .default), pumpManagerSettingsViewModel: DeviceViewModel(), cgmManagerSettingsViewModel: DeviceViewModel(), - servicesViewModel: ServicesViewModel.preview, - criticalEventLogExportViewModel: CriticalEventLogExportViewModel(exporterFactory: MockCriticalEventLogExporterFactory()), - therapySettings: { TherapySettings() }, + servicesViewModel: ServicesViewModel.preview, therapySettings: { TherapySettings() }, initialDosingEnabled: true, automaticDosingStrategy: .automaticBolus, lastLoopCompletion: FakeLastLoopCompletionPublisher().$mockLastLoopCompletion, diff --git a/Loop/Views/CriticalEventLogExportView.swift b/Loop/Views/CriticalEventLogExportView.swift index 9976c7605f..e61eb93ee3 100644 --- a/Loop/Views/CriticalEventLogExportView.swift +++ b/Loop/Views/CriticalEventLogExportView.swift @@ -94,7 +94,7 @@ struct CriticalEventLogExportView: View { } } -struct ActivityViewController: UIViewControllerRepresentable { +fileprivate struct ActivityViewController: UIViewControllerRepresentable { var activityItems: [Any] var applicationActivities: [UIActivity]? = nil diff --git a/Loop/Views/ReportProblemView.swift b/Loop/Views/ReportProblemView.swift deleted file mode 100644 index 23ecc7427c..0000000000 --- a/Loop/Views/ReportProblemView.swift +++ /dev/null @@ -1,136 +0,0 @@ -// -// ReportProblemView.swift -// Loop -// -// Copyright © 2026 LoopKit Authors. All rights reserved. -// - -import LoopKitUI -import SwiftUI - -/// Report a Problem shown on its own, outside Settings. -struct ReportProblemModal: View { - let viewModel: SettingsViewModel - - @Environment(\.dismissAction) private var dismiss - - var body: some View { - NavigationView { - ReportProblemView( - issueReportContext: viewModel.issueReportContext, - generateDiagnosticReport: viewModel.generateDiagnosticReport, - criticalEventLogExportViewModel: viewModel.criticalEventLogExportViewModel - ) - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button(NSLocalizedString("Done", comment: "Button to close the report a problem screen"), action: dismiss) - } - } - } - } -} - -struct ReportProblemView: View { - let issueReportContext: [String: IssueReportContextValue] - let generateDiagnosticReport: (() async -> String)? - let criticalEventLogExportViewModel: CriticalEventLogExportViewModel - - @Environment(\.openURL) private var openURL - - @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 { - NavigationLink(destination: SendIssueReportView( - context: issueReportContext, - generateDiagnosticReport: generateDiagnosticReport - )) { - option( - NSLocalizedString("Send to the Loop Developers", comment: "Report a problem option: submit an issue report to the developers"), - detail: NSLocalizedString("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") - ) - } - - if let generateDiagnosticReport { - Button(action: { shareDiagnosticReport(generateDiagnosticReport) }) { - HStack { - option( - NSLocalizedString("Share a Diagnostic Report", comment: "Report a problem option: share the diagnostic report yourself"), - detail: NSLocalizedString("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() - } - } - } - .disabled(isPreparingReport) - } - - Button(action: { openURL(URL(string: "https://github.com/LoopKit/Loop/issues")!) }) { - option( - NSLocalizedString("Open a GitHub Issue", comment: "Report a problem option: open the public GitHub issues page"), - detail: NSLocalizedString("Public. For bugs you can describe without any personal or health data.", comment: "Report a problem option detail: open the public GitHub issues page") - ) - } - - NavigationLink(destination: CriticalEventLogExportView(viewModel: criticalEventLogExportViewModel)) { - option( - NSLocalizedString("Export Critical Event Logs", comment: "Report a problem option: export the critical event logs"), - detail: NSLocalizedString("Detailed logs as files. Only needed when a developer asks for them.", comment: "Report a problem option detail: export the critical event logs") - ) - } - } - } - .listStyle(.insetGrouped) - .navigationTitle(NSLocalizedString("Report a Problem", comment: "Title of the report a problem screen")) - .navigationBarTitleDisplayMode(.inline) - .sheet(item: $reportFile, onDismiss: removeReportFile) { file in - ActivityViewController(activityItems: [file.url]) - } - } - - private func option(_ title: String, detail: String) -> some View { - VStack(alignment: .leading, spacing: 4) { - Text(title) - .foregroundColor(.primary) - Text(detail) - .font(.footnote) - .foregroundColor(.secondary) - } - .padding(.vertical, 4) - } - - private func shareDiagnosticReport(_ generate: @escaping () async -> String) { - isPreparingReport = true - Task { @MainActor in - let date = Date() - let report = "Generated: \(date)\n\n" + (await generate()) - 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 - } -} diff --git a/Loop/Views/SendIssueReportView.swift b/Loop/Views/SendIssueReportView.swift deleted file mode 100644 index 288e1d0de2..0000000000 --- a/Loop/Views/SendIssueReportView.swift +++ /dev/null @@ -1,249 +0,0 @@ -// -// SendIssueReportView.swift -// Loop -// -// Copyright © 2026 LoopKit Authors. All rights reserved. -// - -import PhotosUI -import SwiftUI - -struct SendIssueReportView: View { - let context: [String: IssueReportContextValue] - let generateDiagnosticReport: (() async -> String)? - - @Environment(\.dismiss) private var dismiss - - @State private var title = "" - @State private var description = "" - @State private var isAnonymous = true - @State private var contact = IssueReport.Contact() - @State private var includeDiagnosticReport = true - @State private var photoSelection: [PhotosPickerItem] = [] - @State private var screenshots: [Screenshot] = [] - @State private var isSending = false - @State private var sentReportID: String? - @State private var sendError: IssueReportSubmitterError? - @State private var showingError = false - - private static let maxDescriptionLength = 20_000 - private static let maxScreenshots = 8 - private static let maxAttachmentBytes = 10_000_000 - - private struct Screenshot: Identifiable { - let id = UUID() - let image: UIImage - let attachment: IssueReport.Attachment - } - - var body: some View { - Form { - Section(footer: Text(String(format: NSLocalizedString("%1$d of %2$d characters", comment: "Issue report description length (1: used)(2: limit)"), description.count, Self.maxDescriptionLength))) { - TextField(NSLocalizedString("Title (optional)", comment: "Placeholder for the issue report title"), text: $title) - ZStack(alignment: .topLeading) { - if description.isEmpty { - Text(NSLocalizedString("Describe what you saw and what you expected to happen", comment: "Placeholder for the issue report description")) - .foregroundColor(Color(.placeholderText)) - .padding(.top, 8) - .padding(.leading, 4) - } - TextEditor(text: $description) - .frame(minHeight: 160) - } - } - - screenshotsSection - - Section(footer: Text(isAnonymous ? - NSLocalizedString("The report won't include your name, contact details or anything else that identifies you.", comment: "Footer for an anonymous issue report") : - NSLocalizedString("Fill in any of these so developers can follow up with you.", comment: "Footer for issue report contact details")) - ) { - Toggle(NSLocalizedString("Submit Anonymously", comment: "Toggle for submitting an issue report without contact details"), isOn: $isAnonymous) - if !isAnonymous { - TextField(NSLocalizedString("Name", comment: "Issue report contact name placeholder"), text: binding(\.name)) - .textContentType(.name) - TextField(NSLocalizedString("Email", comment: "Issue report contact email placeholder"), text: binding(\.email)) - .textContentType(.emailAddress) - .keyboardType(.emailAddress) - .textInputAutocapitalization(.never) - TextField(NSLocalizedString("Zulip Name", comment: "Issue report contact Zulip placeholder"), text: binding(\.zulip)) - .textInputAutocapitalization(.never) - TextField(NSLocalizedString("GitHub Username", comment: "Issue report contact GitHub placeholder"), text: binding(\.github)) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - } - } - - if generateDiagnosticReport != nil { - Section(footer: Text(NSLocalizedString("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(NSLocalizedString("Include Diagnostic Report", comment: "Toggle for attaching the diagnostic report to an issue report"), isOn: $includeDiagnosticReport) - } - } - - Section(header: Text(NSLocalizedString("Also Sent", comment: "Header for the build and device details sent with an issue report")), - footer: Text(NSLocalizedString("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"))) - { - ForEach(context.keys.sorted(), id: \.self) { key in - HStack { - Text(key) - Spacer() - Text(Self.displayValue(context[key])) - .foregroundColor(.secondary) - .multilineTextAlignment(.trailing) - } - .font(.footnote) - } - } - } - .navigationTitle(NSLocalizedString("Send to Developers", comment: "Title of the issue report screen")) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .confirmationAction) { - if isSending { - ProgressView() - } else { - Button(NSLocalizedString("Submit", comment: "Button to submit an issue report to the developers")) { - send() - } - .disabled(!canSend) - } - } - } - .disabled(isSending) - .onChange(of: photoSelection) { _, items in - loadScreenshots(items) - } - .alert(NSLocalizedString("Report Submitted", comment: "Title of the alert after an issue report was submitted"), isPresented: Binding(get: { sentReportID != nil }, set: { if !$0 { sentReportID = nil } })) { - Button(NSLocalizedString("Copy ID", comment: "Button to copy the issue report ID")) { - UIPasteboard.general.string = sentReportID - dismiss() - } - Button(NSLocalizedString("Done", comment: "Button to close the issue report sent alert"), role: .cancel) { - dismiss() - } - } message: { - Text(String(format: NSLocalizedString("Thank you. Your report ID is %1$@. Mention it if you discuss the problem on Zulip or GitHub.", comment: "Message after an issue report was sent (1: report ID)"), sentReportID ?? "")) - } - .alert(NSLocalizedString("Unable to Submit Report", comment: "Title of the alert when an issue report fails"), isPresented: $showingError, presenting: sendError) { error in - if error.isRetryable { - Button(NSLocalizedString("Try Again", comment: "Button to retry sending an issue report")) { - send() - } - } - Button(NSLocalizedString("OK", comment: "Button to close the issue report error alert"), role: .cancel) {} - } message: { error in - Text(error.localizedDescription) - } - } - - private var screenshotsSection: some View { - Section(footer: Text(String(format: NSLocalizedString("Up to %1$d screenshots or photos.", comment: "Footer for issue report screenshots (1: limit)"), Self.maxScreenshots))) { - if !screenshots.isEmpty { - ScrollView(.horizontal) { - HStack { - ForEach(screenshots) { screenshot in - Image(uiImage: screenshot.image) - .resizable() - .scaledToFit() - .frame(height: 120) - .clipShape(RoundedRectangle(cornerRadius: 6)) - } - } - } - } - PhotosPicker(selection: $photoSelection, maxSelectionCount: Self.maxScreenshots, matching: .images) { - Text(screenshots.isEmpty ? - NSLocalizedString("Add Screenshots", comment: "Button to choose screenshots for an issue report") : - NSLocalizedString("Change Screenshots", comment: "Button to change the screenshots on an issue report")) - } - } - } - - private var trimmedDescription: String { - description.trimmingCharacters(in: .whitespacesAndNewlines) - } - - private var canSend: Bool { - !trimmedDescription.isEmpty && description.count <= Self.maxDescriptionLength - } - - private func binding(_ keyPath: WritableKeyPath) -> Binding { - Binding(get: { contact[keyPath: keyPath] ?? "" }, set: { contact[keyPath: keyPath] = $0 }) - } - - private func loadScreenshots(_ items: [PhotosPickerItem]) { - Task { - var loaded: [Screenshot] = [] - for (index, item) in items.enumerated() { - guard let data = try? await item.loadTransferable(type: Data.self), let screenshot = Self.screenshot(from: data, index: index + 1) else { - continue - } - loaded.append(screenshot) - } - screenshots = loaded - } - } - - // The server accepts only PNG and JPEG, so anything else (HEIC from the camera) is converted - private static func screenshot(from data: Data, index: Int) -> Screenshot? { - guard let image = UIImage(data: data) else { - return nil - } - if data.starts(with: [0x89, 0x50, 0x4E, 0x47]), data.count <= maxAttachmentBytes { - return Screenshot(image: image, attachment: .init(filename: "screenshot-\(index).png", contentType: "image/png", data: data)) - } - for quality in [0.8, 0.5, 0.3] { - if let jpeg = image.jpegData(compressionQuality: quality), jpeg.count <= maxAttachmentBytes { - return Screenshot(image: image, attachment: .init(filename: "screenshot-\(index).jpg", contentType: "image/jpeg", data: jpeg)) - } - } - return nil - } - - private static func displayValue(_ value: IssueReportContextValue?) -> String { - switch value { - case .string(let string): return string - case .strings(let strings): return strings.isEmpty ? "–" : strings.joined(separator: ", ") - case nil: return "" - } - } - - private func send() { - isSending = true - Task { - var attachments = screenshots.map(\.attachment) - if includeDiagnosticReport, let generateDiagnosticReport { - let report = await generateDiagnosticReport() - // Stay under the server's per-file limit; decoding repairs a character cut in half - let text = String(decoding: report.utf8.prefix(Self.maxAttachmentBytes - 4), as: UTF8.self) - attachments.append(.init(filename: "diagnostic-report.txt", contentType: "text/plain; charset=utf-8", data: Data(text.utf8))) - } - let report = IssueReport( - title: title.isEmpty ? nil : String(title.prefix(200)), - description: trimmedDescription, - contact: isAnonymous ? nil : Self.trimmed(contact), - context: context, - attachments: attachments - ) - do { - sentReportID = try await IssueReportSubmitter().submit(report) - } catch let error as IssueReportSubmitterError { - sendError = error - showingError = true - } catch { - sendError = .network(error) - showingError = true - } - isSending = false - } - } - - private static func trimmed(_ contact: IssueReport.Contact) -> IssueReport.Contact? { - func clean(_ value: String?) -> String? { - let value = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - return value.isEmpty ? nil : value - } - let result = IssueReport.Contact(name: clean(contact.name), email: clean(contact.email), zulip: clean(contact.zulip), github: clean(contact.github)) - return [result.name, result.email, result.zulip, result.github].allSatisfy({ $0 == nil }) ? nil : result - } -} diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index 9a3515ed61..6f70ef1c91 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -600,14 +600,6 @@ extension SettingsView { private var supportSection: some View { Section(header: SectionHeader(label: NSLocalizedString("Support", comment: "The title of the support section in settings"))) { - NavigationLink(destination: ReportProblemView( - issueReportContext: viewModel.issueReportContext, - generateDiagnosticReport: viewModel.generateDiagnosticReport, - criticalEventLogExportViewModel: viewModel.criticalEventLogExportViewModel - )) { - Text(NSLocalizedString("Report a Problem", comment: "The title text for the report a problem menu item")) - } - ForEach(pluginMenuItems.filter( { $0.section == .support })) { $0.view }