From b783278343abb86f88b5d57bdf31611bb1cbc938 Mon Sep 17 00:00:00 2001 From: marionbarker Date: Tue, 12 May 2026 17:19:24 -0700 Subject: [PATCH 1/8] language modification, add ar, ko; delete ci, hi --- LoopSupport.xcodeproj/project.pbxproj | 4 ++-- LoopSupportKitUI/Localizable.xcstrings | 6 ------ 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/LoopSupport.xcodeproj/project.pbxproj b/LoopSupport.xcodeproj/project.pbxproj index 7d1cc0b..e87b962 100644 --- a/LoopSupport.xcodeproj/project.pbxproj +++ b/LoopSupport.xcodeproj/project.pbxproj @@ -298,15 +298,15 @@ Base, fi, he, - hi, ja, "pt-BR", sk, sv, vi, - ce, hu, uk, + ko, + ar, ); mainGroup = A936A90425BC99CB0085119C; packageReferences = ( diff --git a/LoopSupportKitUI/Localizable.xcstrings b/LoopSupportKitUI/Localizable.xcstrings index dc7e2c4..69c0c08 100644 --- a/LoopSupportKitUI/Localizable.xcstrings +++ b/LoopSupportKitUI/Localizable.xcstrings @@ -123,12 +123,6 @@ "value" : "Continue" } }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "जारी" - } - }, "it" : { "stringUnit" : { "state" : "translated", From 21957dfec01d401edf8c7f9574a2975b60e7592b Mon Sep 17 00:00:00 2001 From: Marion Barker <19607791+marionbarker@users.noreply.github.com> Date: Sat, 26 Sep 2026 06:09:36 -0700 Subject: [PATCH 2/8] Updated translations from lokalise on Fri Sep 25 13:06:28 PDT 2026 (#31) --- LoopSupportKitUI/Localizable.xcstrings | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/LoopSupportKitUI/Localizable.xcstrings b/LoopSupportKitUI/Localizable.xcstrings index 69c0c08..c9d9828 100644 --- a/LoopSupportKitUI/Localizable.xcstrings +++ b/LoopSupportKitUI/Localizable.xcstrings @@ -144,7 +144,7 @@ "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Ga Verder" + "value" : "Ga verder" } }, "pl" : { @@ -476,7 +476,7 @@ "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Niet Delen" + "value" : "Niet delen" } }, "pl" : { @@ -553,7 +553,7 @@ "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Deel Gegevens" + "value" : "Deel gegevens" } }, "pl" : { @@ -630,7 +630,7 @@ "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Deel Gebruiksgegevens" + "value" : "Deel gebruiksgegevens" } }, "pl" : { @@ -707,7 +707,7 @@ "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Deel Alleen Versie" + "value" : "Deel alleen versie" } }, "pl" : { @@ -790,7 +790,7 @@ "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Bugrapport Indienen" + "value" : "Bugrapport indienen" } }, "pl" : { @@ -873,7 +873,7 @@ "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Gebruiksgegevens Delen" + "value" : "Gebruiksgegevens delen" } }, "pl" : { From cfc6de8addba9e2e56270b77261864b3a2b712a0 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 13 Oct 2026 17:05:54 -0500 Subject: [PATCH 3/8] Send analytics to ingest.loopkit.org Usage data now goes to the LoopKit ingest endpoint instead of Amplitude's default API host. --- LoopSupportKitUI/LoopKitAnalytics.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/LoopSupportKitUI/LoopKitAnalytics.swift b/LoopSupportKitUI/LoopKitAnalytics.swift index 73f39af..6dbc69a 100644 --- a/LoopSupportKitUI/LoopKitAnalytics.swift +++ b/LoopSupportKitUI/LoopKitAnalytics.swift @@ -59,6 +59,7 @@ public class LoopKitAnalytics { let amplitude = Amplitude() amplitude.setTrackingOptions(AMPTrackingOptions().disableCity().disableCarrier().disableIDFA().disableLatLng().disableIPAddress().disableRegion()) amplitude.initializeApiKey("7dd7414785560c0dd1ef802ac10c00b4") + amplitude.setServerUrl("https://ingest.loopkit.org/v1/events") client = amplitude client?.logEvent("LoopKitAnalytics instantiation") #endif From 4983fd6b65bcee7bb09b2d54e0bb499807d0c251 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 13 Oct 2026 19:35:40 -0500 Subject: [PATCH 4/8] Include build details on the instantiation event Adds the build number and the LoopWorkspace and Loop git branch and revision from BuildDetails.plist, so builds sharing a version string can be told apart. This event is sent at the version-only sharing level. --- LoopSupportKitUI/LoopKitAnalytics.swift | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/LoopSupportKitUI/LoopKitAnalytics.swift b/LoopSupportKitUI/LoopKitAnalytics.swift index 6dbc69a..0c74f10 100644 --- a/LoopSupportKitUI/LoopKitAnalytics.swift +++ b/LoopSupportKitUI/LoopKitAnalytics.swift @@ -61,10 +61,26 @@ public class LoopKitAnalytics { amplitude.initializeApiKey("7dd7414785560c0dd1ef802ac10c00b4") amplitude.setServerUrl("https://ingest.loopkit.org/v1/events") client = amplitude - client?.logEvent("LoopKitAnalytics instantiation") + client?.logEvent("LoopKitAnalytics instantiation", withEventProperties: buildProperties) #endif } + private var buildProperties: [String: Any] { + var properties: [String: Any] = [:] + properties["buildNumber"] = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") + guard let url = Bundle.main.url(forResource: "BuildDetails", withExtension: "plist"), + let data = try? Data(contentsOf: url), + let details = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any] + else { + return properties + } + properties["workspaceBranch"] = details["com-loopkit-LoopWorkspace-git-branch"] + properties["workspaceRevision"] = details["com-loopkit-LoopWorkspace-git-revision"] + properties["loopBranch"] = details["com-loopkit-Loop-git-branch"] + properties["loopRevision"] = details["com-loopkit-Loop-git-revision"] + return properties + } + public func recordAnalyticsEvent(_ name: String, withProperties properties: [AnyHashable : Any]?, outOfSession: Bool) { if usageDataPrivacyPreference == .shareUsageDetailsWithDevelopers { client?.logEvent(name, withEventProperties: properties, outOfSession: outOfSession) From cedd5cb408077c20b4b38fcafaa4cf9a61e4a68c Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 13 Oct 2026 20:10:46 -0500 Subject: [PATCH 5/8] Include build customizations on the instantiation event Sends the identifiers Loop's build-details script collected from .loopcustomization marker files. --- LoopSupportKitUI/LoopKitAnalytics.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/LoopSupportKitUI/LoopKitAnalytics.swift b/LoopSupportKitUI/LoopKitAnalytics.swift index 0c74f10..6828407 100644 --- a/LoopSupportKitUI/LoopKitAnalytics.swift +++ b/LoopSupportKitUI/LoopKitAnalytics.swift @@ -78,6 +78,7 @@ public class LoopKitAnalytics { properties["workspaceRevision"] = details["com-loopkit-LoopWorkspace-git-revision"] properties["loopBranch"] = details["com-loopkit-Loop-git-branch"] properties["loopRevision"] = details["com-loopkit-Loop-git-revision"] + properties["customizations"] = details["com-loopkit-Loop-customizations"] return properties } From 95a1ba84a6a2695db896fd28d5e2dc94176b4328 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Wed, 21 Oct 2026 07:04:09 -0500 Subject: [PATCH 6/8] Provide Report a Problem from LoopSupport (#40) * Require iOS 17.6 * Provide Report a Problem from LoopSupport * Leave the GitHub issues link to Loop's Report a Problem screen Loop now lists every way to report a problem on one screen, including GitHub issues, so LoopSupport no longer adds its own Submit Bug Report row to Support. Also drops supportMenuItem, which nothing calls. * Provide Report a Problem from LoopSupport The Report a Problem screen, the developer report form and its submitter move here from Loop, so each support plugin decides what its users can report and where it goes. LoopSupport adds one Report a Problem row to Support with all four options: send to the Loop developers, share a diagnostic report, open a GitHub issue, and export critical event logs through the app. The report context is built from the app's bundle and BuildDetails plus the pump and CGM status the delegate provides. * Generate the diagnostic report through dev's completion handler * update string catalog --------- Co-authored-by: marionbarker --- LoopSupport.xcodeproj/project.pbxproj | 28 +- .../IssueReport/IssueReportContext.swift | 45 ++++ .../IssueReport/IssueReportSubmitter.swift | 139 ++++++++++ .../IssueReport/ReportProblemView.swift | 161 +++++++++++ .../IssueReport/SendIssueReportView.swift | 249 ++++++++++++++++++ LoopSupportKitUI/Localizable.xcstrings | 121 +++++++++ LoopSupportKitUI/LoopSupportUI.swift | 17 +- 7 files changed, 746 insertions(+), 14 deletions(-) create mode 100644 LoopSupportKitUI/IssueReport/IssueReportContext.swift create mode 100644 LoopSupportKitUI/IssueReport/IssueReportSubmitter.swift create mode 100644 LoopSupportKitUI/IssueReport/ReportProblemView.swift create mode 100644 LoopSupportKitUI/IssueReport/SendIssueReportView.swift diff --git a/LoopSupport.xcodeproj/project.pbxproj b/LoopSupport.xcodeproj/project.pbxproj index e87b962..728533e 100644 --- a/LoopSupport.xcodeproj/project.pbxproj +++ b/LoopSupport.xcodeproj/project.pbxproj @@ -21,6 +21,10 @@ B66D1F552E6A752300471149 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = B66D1F542E6A752300471149 /* Localizable.xcstrings */; }; C12ECD05289560B90089EB07 /* LoopSupportKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A936A95425BC9A190085119C /* LoopSupportKitUI.framework */; platformFilter = ios; }; 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 */; }; + 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 */; }; C15349062960AA9000B47074 /* UsageDataPrivacyPreferenceMenuItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = C15349052960AA9000B47074 /* UsageDataPrivacyPreferenceMenuItem.swift */; }; C1BE9741295E3800008BA25D /* LoopKitAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BE9740295E3800008BA25D /* LoopKitAnalytics.swift */; }; @@ -84,6 +88,10 @@ A9CE800A25BCACAE00C04F9B /* LoopSupportPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopSupportPlugin.swift; sourceTree = ""; }; A9CE801225BCAE4000C04F9B /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 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 = ""; }; + 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 = ""; }; C15349052960AA9000B47074 /* UsageDataPrivacyPreferenceMenuItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsageDataPrivacyPreferenceMenuItem.swift; sourceTree = ""; }; C1BE9740295E3800008BA25D /* LoopKitAnalytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopKitAnalytics.swift; sourceTree = ""; }; @@ -136,6 +144,7 @@ isa = PBXGroup; children = ( C14A7E7A295F2EA700CD87B4 /* Views */, + C1A55E022E9F000000B10000 /* IssueReport */, A936A95625BC9A190085119C /* LoopSupportKitUI.h */, A936A95725BC9A190085119C /* Info.plist */, B66D1F542E6A752300471149 /* Localizable.xcstrings */, @@ -185,6 +194,17 @@ name = Frameworks; sourceTree = ""; }; + C1A55E022E9F000000B10000 /* IssueReport */ = { + isa = PBXGroup; + children = ( + C1A55E022E9F000000B10001 /* IssueReportContext.swift */, + C1A55E022E9F000000B10003 /* IssueReportSubmitter.swift */, + C1A55E022E9F000000B10005 /* ReportProblemView.swift */, + C1A55E022E9F000000B10007 /* SendIssueReportView.swift */, + ); + path = IssueReport; + sourceTree = ""; + }; C14A7E7A295F2EA700CD87B4 /* Views */ = { isa = PBXGroup; children = ( @@ -349,6 +369,10 @@ A9CE7DA125BC9C0200C04F9B /* OSLog.swift in Sources */, C15349062960AA9000B47074 /* UsageDataPrivacyPreferenceMenuItem.swift in Sources */, C14A7E7C295F2EC100CD87B4 /* UsageDataPrivacyPreferenceView.swift in Sources */, + C1A55E022E9F000000B10002 /* IssueReportContext.swift in Sources */, + C1A55E022E9F000000B10004 /* IssueReportSubmitter.swift in Sources */, + C1A55E022E9F000000B10006 /* ReportProblemView.swift in Sources */, + C1A55E022E9F000000B10008 /* SendIssueReportView.swift in Sources */, A9CE7D8425BC9B3700C04F9B /* LocalizedString.swift in Sources */, A9CE800325BCAC8700C04F9B /* LoopSupportUI.swift in Sources */, C1BE9741295E3800008BA25D /* LoopKitAnalytics.swift in Sources */, @@ -477,7 +501,7 @@ GCC_WARN_UNUSED_LABEL = YES; GCC_WARN_UNUSED_PARAMETER = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.1; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; LOCALIZED_STRING_MACRO_NAMES = ( NSLocalizedString, @@ -582,7 +606,7 @@ GCC_WARN_UNUSED_LABEL = YES; GCC_WARN_UNUSED_PARAMETER = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.1; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; LOCALIZED_STRING_MACRO_NAMES = ( NSLocalizedString, diff --git a/LoopSupportKitUI/IssueReport/IssueReportContext.swift b/LoopSupportKitUI/IssueReport/IssueReportContext.swift new file mode 100644 index 0000000..c9f9a19 --- /dev/null +++ b/LoopSupportKitUI/IssueReport/IssueReportContext.swift @@ -0,0 +1,45 @@ +// +// IssueReportContext.swift +// LoopSupportKitUI +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import UIKit +import LoopKit +import LoopKitUI + +enum IssueReportContext { + /// Build, device and pump/CGM details sent with every issue report. + @MainActor + static func current(supportInfo: SupportInfoProvider?) -> [String: IssueReportContextValue] { + let info = Bundle.main.infoDictionary ?? [:] + let buildDetails = Bundle.main.url(forResource: "BuildDetails", withExtension: "plist") + .flatMap { NSDictionary(contentsOf: $0) as? [String: Any] } ?? [:] + + var context: [String: IssueReportContextValue] = [ + "appVersion": .string(info["CFBundleShortVersionString"] as? String ?? ""), + "buildNumber": .string(info["CFBundleVersion"] as? String ?? ""), + "customizations": .strings(buildDetails["com-loopkit-Loop-customizations"] as? [String] ?? []), + "deviceModel": .string(modelIdentifier), + "osVersion": .string(UIDevice.current.systemVersion), + ] + let branch = buildDetails["com-loopkit-LoopWorkspace-git-branch"] as? String ?? buildDetails["com-loopkit-Loop-git-branch"] as? String + context["branch"] = branch.map { .string($0) } + context["workspaceRevision"] = (buildDetails["com-loopkit-LoopWorkspace-git-revision"] as? String).map { .string($0) } + context["loopRevision"] = (buildDetails["com-loopkit-Loop-git-revision"] as? String).map { .string($0) } + context["pumpModel"] = supportInfo?.pumpStatus.map { .string($0.device.model ?? "Unknown") } + context["cgmModel"] = supportInfo?.cgmStatus.map { .string($0.device?.model ?? "Unknown") } + return context + } + + private static var modelIdentifier: String { + #if targetEnvironment(simulator) + return "\(UIDevice.current.model)Simulator" + #else + var info = utsname() + uname(&info) + return withUnsafePointer(to: &info.machine) { $0.withMemoryRebound(to: CChar.self, capacity: 1) { String(validatingUTF8: $0) } } ?? "unknown" + #endif + } +} diff --git a/LoopSupportKitUI/IssueReport/IssueReportSubmitter.swift b/LoopSupportKitUI/IssueReport/IssueReportSubmitter.swift new file mode 100644 index 0000000..13b4f3c --- /dev/null +++ b/LoopSupportKitUI/IssueReport/IssueReportSubmitter.swift @@ -0,0 +1,139 @@ +// +// IssueReportSubmitter.swift +// LoopSupportKitUI +// +// 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 LocalizedString("The report is too large. Try fewer or smaller screenshots.", comment: "Issue report error when the upload is too large") + case .server(415, _): + return LocalizedString("One of the attachments isn't a supported image type.", comment: "Issue report error when an attachment type is refused") + case .server(429, _): + return LocalizedString("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: LocalizedString("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 LocalizedString("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/LoopSupportKitUI/IssueReport/ReportProblemView.swift b/LoopSupportKitUI/IssueReport/ReportProblemView.swift new file mode 100644 index 0000000..bf30c93 --- /dev/null +++ b/LoopSupportKitUI/IssueReport/ReportProblemView.swift @@ -0,0 +1,161 @@ +// +// ReportProblemView.swift +// LoopSupportKitUI +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import SwiftUI +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 { + NavigationLink(destination: SendIssueReportView( + context: IssueReportContext.current(supportInfo: delegate), + generateDiagnosticReport: delegate.map { delegate in { await delegate.generateIssueReport() } } + )) { + option( + LocalizedString("Send to the Loop Developers", comment: "Report a problem option: submit an issue report to the developers"), + detail: LocalizedString("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 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() + } + } + } + .disabled(isPreparingReport) + } + + Button(action: { delegate?.openURL(url: URL(string: "https://github.com/LoopKit/Loop/issues")!) }) { + option( + LocalizedString("Open a GitHub Issue", comment: "Report a problem option: open the public GitHub issues page"), + detail: LocalizedString("Public. For bugs you can describe without any personal or health data.", comment: "Report a problem option detail: open the public GitHub issues page") + ) + } + + if let delegate { + NavigationLink(destination: delegate.criticalEventLogExportView()) { + option( + LocalizedString("Export Critical Event Logs", comment: "Report a problem option: export the critical event logs"), + detail: LocalizedString("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(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 { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .foregroundColor(.primary) + Text(detail) + .font(.footnote) + .foregroundColor(.secondary) + } + .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 { + let support: LoopSupportUI + + var body: some View { + NavigationLink(destination: ReportProblemView(delegate: support.delegate)) { + Text(LocalizedString("Report a Problem", comment: "The title text for the report a problem menu item")) + } + } +} + +/// Report a Problem shown on its own, outside settings. +struct ReportProblemModal: View { + let delegate: SupportUIDelegate? + + @Environment(\.dismissAction) private var dismiss + + var body: some View { + NavigationView { + ReportProblemView(delegate: delegate) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button(LocalizedString("Done", comment: "Button to close the report a problem screen"), action: dismiss) + } + } + } + } +} + +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 + generateIssueReport { continuation.resume(returning: $0) } + } + } +} diff --git a/LoopSupportKitUI/IssueReport/SendIssueReportView.swift b/LoopSupportKitUI/IssueReport/SendIssueReportView.swift new file mode 100644 index 0000000..9c32a6f --- /dev/null +++ b/LoopSupportKitUI/IssueReport/SendIssueReportView.swift @@ -0,0 +1,249 @@ +// +// SendIssueReportView.swift +// LoopSupportKitUI +// +// 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: LocalizedString("%1$d of %2$d characters", comment: "Issue report description length (1: used)(2: limit)"), description.count, Self.maxDescriptionLength))) { + TextField(LocalizedString("Title (optional)", comment: "Placeholder for the issue report title"), text: $title) + ZStack(alignment: .topLeading) { + if description.isEmpty { + Text(LocalizedString("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 ? + LocalizedString("The report won't include your name, contact details or anything else that identifies you.", comment: "Footer for an anonymous issue report") : + LocalizedString("Fill in any of these so developers can follow up with you.", comment: "Footer for issue report contact details")) + ) { + Toggle(LocalizedString("Submit Anonymously", comment: "Toggle for submitting an issue report without contact details"), isOn: $isAnonymous) + if !isAnonymous { + TextField(LocalizedString("Name", comment: "Issue report contact name placeholder"), text: binding(\.name)) + .textContentType(.name) + TextField(LocalizedString("Email", comment: "Issue report contact email placeholder"), text: binding(\.email)) + .textContentType(.emailAddress) + .keyboardType(.emailAddress) + .textInputAutocapitalization(.never) + TextField(LocalizedString("Zulip Name", comment: "Issue report contact Zulip placeholder"), text: binding(\.zulip)) + .textInputAutocapitalization(.never) + TextField(LocalizedString("GitHub Username", comment: "Issue report contact GitHub placeholder"), text: binding(\.github)) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } + } + + if generateDiagnosticReport != nil { + 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) + } + } + + Section(header: Text(LocalizedString("Also Sent", comment: "Header for the build and device details sent with an issue report")), + footer: Text(LocalizedString("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(LocalizedString("Send to Developers", comment: "Title of the issue report screen")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + if isSending { + ProgressView() + } else { + Button(LocalizedString("Submit", comment: "Button to submit an issue report to the developers")) { + send() + } + .disabled(!canSend) + } + } + } + .disabled(isSending) + .onChange(of: photoSelection) { _, items in + loadScreenshots(items) + } + .alert(LocalizedString("Report Submitted", comment: "Title of the alert after an issue report was submitted"), isPresented: Binding(get: { sentReportID != nil }, set: { if !$0 { sentReportID = nil } })) { + Button(LocalizedString("Copy ID", comment: "Button to copy the issue report ID")) { + UIPasteboard.general.string = sentReportID + dismiss() + } + Button(LocalizedString("Done", comment: "Button to close the issue report sent alert"), role: .cancel) { + dismiss() + } + } message: { + Text(String(format: LocalizedString("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(LocalizedString("Unable to Submit Report", comment: "Title of the alert when an issue report fails"), isPresented: $showingError, presenting: sendError) { error in + if error.isRetryable { + Button(LocalizedString("Try Again", comment: "Button to retry sending an issue report")) { + send() + } + } + Button(LocalizedString("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: LocalizedString("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 ? + LocalizedString("Add Screenshots", comment: "Button to choose screenshots for an issue report") : + LocalizedString("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/LoopSupportKitUI/Localizable.xcstrings b/LoopSupportKitUI/Localizable.xcstrings index c9d9828..3ca37b4 100644 --- a/LoopSupportKitUI/Localizable.xcstrings +++ b/LoopSupportKitUI/Localizable.xcstrings @@ -1,6 +1,15 @@ { "sourceLanguage" : "en", "strings" : { + "%1$d of %2$d characters" : { + "comment" : "Issue report description length (1: used)(2: limit)" + }, + "Add Screenshots" : { + "comment" : "Button to choose screenshots for an issue report" + }, + "Also Sent" : { + "comment" : "Header for the build and device details sent with an issue report" + }, "Anonymously share minimal data about this Loop version, to help developers know how many people are using Loop. Which device, and operating system version will also be shared." : { "comment" : "Description in UsageDataPrivacyPreferenceView for shareInstallationStatsOnly", "localizations" : { @@ -78,6 +87,12 @@ } } }, + "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" + }, + "Change Screenshots" : { + "comment" : "Button to change the screenshots on an issue report" + }, "Continue" : { "comment" : "Button title for choosing onboarding without nightscout", "localizations" : { @@ -203,6 +218,15 @@ } } }, + "Copy ID" : { + "comment" : "Button to copy the issue report ID" + }, + "Describe what you saw and what you expected to happen" : { + "comment" : "Placeholder for the issue report description" + }, + "Detailed logs as files. Only needed when a developer asks for them." : { + "comment" : "Report a problem option detail: export the critical event logs" + }, "Do not share any data." : { "comment" : "Description in UsageDataPrivacyPreferenceView for no sharing", "localizations" : { @@ -280,6 +304,21 @@ } } }, + "Done" : { + "comment" : "Button to close the issue report sent alert\nButton to close the report a problem screen" + }, + "Email" : { + "comment" : "Issue report contact email placeholder" + }, + "Export Critical Event Logs" : { + "comment" : "Report a problem option: export the critical event logs" + }, + "Fill in any of these so developers can follow up with you." : { + "comment" : "Footer for issue report contact details" + }, + "GitHub Username" : { + "comment" : "Issue report contact GitHub placeholder" + }, "In addition to version information, anonymously share data about how Loop is being used on your phone. Usage data includes events like opening loop, bolusing, adding carbs, and navigating between screens. It does not include health data like glucose values or dosing amounts." : { "comment" : "Description in UsageDataPrivacyPreferenceView for shareUsageDetailsWithDevelopers", "localizations" : { @@ -357,6 +396,9 @@ } } }, + "Include Diagnostic Report" : { + "comment" : "Toggle for attaching the diagnostic report to an issue report" + }, "LoopKit Analytics" : { "comment" : "Title for LoopKit Analytics", "localizations" : { @@ -434,6 +476,9 @@ } } }, + "Name" : { + "comment" : "Issue report contact name placeholder" + }, "No Sharing" : { "comment" : "Title in UsageDataPrivacyPreferenceView for no sharing", "localizations" : { @@ -511,6 +556,42 @@ } } }, + "OK" : { + "comment" : "Button to close the issue report error alert" + }, + "One of the attachments isn't a supported image type." : { + "comment" : "Issue report error when an attachment type is refused" + }, + "Open a GitHub Issue" : { + "comment" : "Report a problem option: open the public GitHub issues page" + }, + "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" + }, + "Public. For bugs you can describe without any personal or health data." : { + "comment" : "Report a problem option detail: open the public GitHub issues page" + }, + "Report a Problem" : { + "comment" : "The title text for the report a problem menu item\nTitle of the report a problem screen" + }, + "Report Submitted" : { + "comment" : "Title of the alert after an issue report was submitted" + }, + "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 a Diagnostic Report" : { + "comment" : "Report a problem option: share the diagnostic report yourself" + }, "Share Data" : { "comment" : "Title on UsageDataPrivacyPreferenceView", "localizations" : { @@ -742,8 +823,15 @@ } } }, + "Submit" : { + "comment" : "Button to submit an issue report to the developers" + }, + "Submit Anonymously" : { + "comment" : "Toggle for submitting an issue report without contact details" + }, "Submit Bug Report" : { "comment" : "Navigation link title for Submit Bug Report", + "extractionState" : "stale", "localizations" : { "cs" : { "stringUnit" : { @@ -825,6 +913,36 @@ } } }, + "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)" + }, + "The report is too large. Try fewer or smaller screenshots." : { + "comment" : "Issue report error when the upload is too large" + }, + "The report won't include your name, contact details or anything else that identifies you." : { + "comment" : "Footer for an anonymous issue report" + }, + "The server couldn't accept the report (%1$d %2$@)." : { + "comment" : "Issue report error with HTTP status (1) and reason (2)" + }, + "The server sent an unexpected response." : { + "comment" : "Issue report error for an unreadable response" + }, + "Title (optional)" : { + "comment" : "Placeholder for the issue report title" + }, + "Too many reports have been sent from this network recently. Please try again later." : { + "comment" : "Issue report error when rate limited" + }, + "Try Again" : { + "comment" : "Button to retry sending an issue report" + }, + "Unable to Submit Report" : { + "comment" : "Title of the alert when an issue report fails" + }, + "Up to %1$d screenshots or photos." : { + "comment" : "Footer for issue report screenshots (1: limit)" + }, "Usage Data Sharing" : { "comment" : "Navigation Link Title for Usage Data Sharing", "localizations" : { @@ -984,6 +1102,9 @@ } } } + }, + "Zulip Name" : { + "comment" : "Issue report contact Zulip placeholder" } }, "version" : "1.0" diff --git a/LoopSupportKitUI/LoopSupportUI.swift b/LoopSupportKitUI/LoopSupportUI.swift index 45f6790..02ea6f2 100644 --- a/LoopSupportKitUI/LoopSupportUI.swift +++ b/LoopSupportKitUI/LoopSupportUI.swift @@ -35,28 +35,21 @@ public final class LoopSupportUI: SupportUI { public func getScenarios(from scenarioURLs: [URL]) -> [LoopScenario] { [] } - public func supportMenuItem(supportInfoProvider: SupportInfoProvider, urlHandler: @escaping (URL) -> Void) -> AnyView? { - return AnyView(Button(LocalizedString("Submit Bug Report", comment: "Navigation link title for Submit Bug Report"), action: { - let url = URL(string: "https://github.com/LoopKit/Loop/issues")! - urlHandler(url) - })) - } - public weak var delegate: SupportUIDelegate? public func initializationComplete(for services: [LoopKit.Service]) { } public func configurationMenuItems() -> [LoopKitUI.CustomMenuItem] { - let view = Button(LocalizedString("Submit Bug Report", comment: "Navigation link title for Submit Bug Report"), action: { - let url = URL(string: "https://github.com/LoopKit/Loop/issues")! - self.delegate?.openURL(url: url) - }) return [ - CustomMenuItem(section: .support, view: AnyView(view)), + CustomMenuItem(section: .support, view: AnyView(ReportProblemMenuItem(support: self))), CustomMenuItem(section: .configuration, view: AnyView(UsageDataPrivacyPreferenceMenuItem())), ] } + public func reportProblemView() -> AnyView? { + AnyView(ReportProblemModal(delegate: delegate)) + } + public func loopWillReset() { } public func loopDidReset() { } From a647bced24118717a842d53bea83ac7823dc6f32 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Wed, 21 Oct 2026 23:23:09 -0500 Subject: [PATCH 7/8] View the diagnostic report before sharing or sending it (#41) Share a Diagnostic Report now opens a viewer showing the report, with a Share button for saving or sending it as a file. The developer report form gains View Diagnostic Report under Include Diagnostic Report, so the attachment can be checked before submitting. --- LoopSupport.xcodeproj/project.pbxproj | 4 + .../IssueReport/DiagnosticReportView.swift | 100 ++++++++++++++++++ .../IssueReport/ReportProblemView.swift | 69 ++---------- .../IssueReport/SendIssueReportView.swift | 7 +- LoopSupportKitUI/Localizable.xcstrings | 18 +++- 5 files changed, 131 insertions(+), 67 deletions(-) create mode 100644 LoopSupportKitUI/IssueReport/DiagnosticReportView.swift 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" : { From 776b4754592ca930d0759b6ff057d878dc0ae57b Mon Sep 17 00:00:00 2001 From: Marion Barker <19607791+marionbarker@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:18:15 -0700 Subject: [PATCH 8/8] Updated translations from lokalise on Thu Oct 22 11:50:14 PDT 2026 (#43) --- LoopSupport.xcodeproj/project.pbxproj | 30 +++++++++++++-------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/LoopSupport.xcodeproj/project.pbxproj b/LoopSupport.xcodeproj/project.pbxproj index 8792a70..4fe0549 100644 --- a/LoopSupport.xcodeproj/project.pbxproj +++ b/LoopSupport.xcodeproj/project.pbxproj @@ -21,13 +21,13 @@ B66D1F552E6A752300471149 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = B66D1F542E6A752300471149 /* Localizable.xcstrings */; }; C12ECD05289560B90089EB07 /* LoopSupportKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A936A95425BC9A190085119C /* LoopSupportKitUI.framework */; platformFilter = ios; }; C12ECD06289560B90089EB07 /* LoopSupportKitUI.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = A936A95425BC9A190085119C /* LoopSupportKitUI.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + C14A7E7C295F2EC100CD87B4 /* UsageDataPrivacyPreferenceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C14A7E7B295F2EC100CD87B4 /* UsageDataPrivacyPreferenceView.swift */; }; + C15349062960AA9000B47074 /* UsageDataPrivacyPreferenceMenuItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = C15349052960AA9000B47074 /* UsageDataPrivacyPreferenceMenuItem.swift */; }; 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 */; }; - C15349062960AA9000B47074 /* UsageDataPrivacyPreferenceMenuItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = C15349052960AA9000B47074 /* UsageDataPrivacyPreferenceMenuItem.swift */; }; + C1A55E022E9F000000B1000A /* DiagnosticReportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A55E022E9F000000B10009 /* DiagnosticReportView.swift */; }; C1BE9741295E3800008BA25D /* LoopKitAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1BE9740295E3800008BA25D /* LoopKitAnalytics.swift */; }; C1BE9744295E40B1008BA25D /* Amplitude in Frameworks */ = {isa = PBXBuildFile; productRef = C1BE9743295E40B1008BA25D /* Amplitude */; }; /* End PBXBuildFile section */ @@ -89,13 +89,13 @@ A9CE800A25BCACAE00C04F9B /* LoopSupportPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopSupportPlugin.swift; sourceTree = ""; }; A9CE801225BCAE4000C04F9B /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; B66D1F542E6A752300471149 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; + C14A7E7B295F2EC100CD87B4 /* UsageDataPrivacyPreferenceView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UsageDataPrivacyPreferenceView.swift; sourceTree = ""; }; + C15349052960AA9000B47074 /* UsageDataPrivacyPreferenceMenuItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsageDataPrivacyPreferenceMenuItem.swift; 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 = ""; }; - C15349052960AA9000B47074 /* UsageDataPrivacyPreferenceMenuItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsageDataPrivacyPreferenceMenuItem.swift; sourceTree = ""; }; + C1A55E022E9F000000B10009 /* DiagnosticReportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiagnosticReportView.swift; sourceTree = ""; }; C1BE9740295E3800008BA25D /* LoopKitAnalytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopKitAnalytics.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -196,6 +196,15 @@ name = Frameworks; sourceTree = ""; }; + C14A7E7A295F2EA700CD87B4 /* Views */ = { + isa = PBXGroup; + children = ( + C14A7E7B295F2EC100CD87B4 /* UsageDataPrivacyPreferenceView.swift */, + C15349052960AA9000B47074 /* UsageDataPrivacyPreferenceMenuItem.swift */, + ); + path = Views; + sourceTree = ""; + }; C1A55E022E9F000000B10000 /* IssueReport */ = { isa = PBXGroup; children = ( @@ -208,15 +217,6 @@ path = IssueReport; sourceTree = ""; }; - C14A7E7A295F2EA700CD87B4 /* Views */ = { - isa = PBXGroup; - children = ( - C14A7E7B295F2EC100CD87B4 /* UsageDataPrivacyPreferenceView.swift */, - C15349052960AA9000B47074 /* UsageDataPrivacyPreferenceMenuItem.swift */, - ); - path = Views; - sourceTree = ""; - }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */