From e0ec5f65e63f1f2eb85c747d91a77761241d14da Mon Sep 17 00:00:00 2001 From: codebymini Date: Tue, 12 Aug 2025 21:33:03 +0200 Subject: [PATCH] Add sharing remote settings with QR code --- LoopFollow.xcodeproj/project.pbxproj | 20 +- LoopFollow/Helpers/QRCodeGenerator.swift | 95 ++++++++ .../Helpers/Views/QRCodeDisplayView.swift | 77 +++++++ LoopFollow/Log/LogManager.swift | 1 + .../Settings/RemoteCommandSettings.swift | 217 ++++++++++++++++++ .../Remote/Settings/RemoteSettingsView.swift | 125 ++++++++++ .../Settings/RemoteSettingsViewModel.swift | 88 +++++++ .../Settings/URLTokenValidationView.swift | 99 ++++++++ LoopFollow/Settings/SettingsMenuView.swift | 6 + 9 files changed, 726 insertions(+), 2 deletions(-) create mode 100644 LoopFollow/Helpers/QRCodeGenerator.swift create mode 100644 LoopFollow/Helpers/Views/QRCodeDisplayView.swift create mode 100644 LoopFollow/Remote/Settings/RemoteCommandSettings.swift create mode 100644 LoopFollow/Remote/Settings/URLTokenValidationView.swift diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index cb8e910d8..e8b08ed19 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -10,9 +10,13 @@ 3F1335F351590E573D8E6962 /* Pods_LoopFollow.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A7D55B42A22051DAD69E89D0 /* Pods_LoopFollow.framework */; }; 654132E72E19EA7E00BDBE08 /* SimpleQRCodeScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654132E62E19EA7E00BDBE08 /* SimpleQRCodeScannerView.swift */; }; 654132EA2E19F24800BDBE08 /* TOTPGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654132E92E19F24800BDBE08 /* TOTPGenerator.swift */; }; - 6541341C2E1DC28000BDBE08 /* DateExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6541341B2E1DC28000BDBE08 /* DateExtensions.swift */; }; 654134182E1DC09700BDBE08 /* OverridePresetsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654134172E1DC09700BDBE08 /* OverridePresetsView.swift */; }; 6541341A2E1DC27900BDBE08 /* OverridePresetData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654134192E1DC27900BDBE08 /* OverridePresetData.swift */; }; + 6541341C2E1DC28000BDBE08 /* DateExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6541341B2E1DC28000BDBE08 /* DateExtensions.swift */; }; + 656F8C102E49F36F0008DC1D /* QRCodeDisplayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 656F8C0F2E49F36F0008DC1D /* QRCodeDisplayView.swift */; }; + 656F8C122E49F3780008DC1D /* QRCodeGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 656F8C112E49F3780008DC1D /* QRCodeGenerator.swift */; }; + 656F8C142E49F3D20008DC1D /* RemoteCommandSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 656F8C132E49F3D20008DC1D /* RemoteCommandSettings.swift */; }; + 65E153C32E4BB69100693A4F /* URLTokenValidationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E153C22E4BB69100693A4F /* URLTokenValidationView.swift */; }; DD0247592DB2E89600FCADF6 /* AlarmCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0247582DB2E89600FCADF6 /* AlarmCondition.swift */; }; DD0247712DB4337700FCADF6 /* BuildExpireCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD02475B2DB2E8FB00FCADF6 /* BuildExpireCondition.swift */; }; DD0650A92DCA8A10004D3B41 /* AlarmBGSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0650A82DCA8A10004D3B41 /* AlarmBGSection.swift */; }; @@ -393,9 +397,13 @@ 059B0FA59AABFE72FE13DDDA /* Pods-LoopFollow.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-LoopFollow.release.xcconfig"; path = "Target Support Files/Pods-LoopFollow/Pods-LoopFollow.release.xcconfig"; sourceTree = ""; }; 654132E62E19EA7E00BDBE08 /* SimpleQRCodeScannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleQRCodeScannerView.swift; sourceTree = ""; }; 654132E92E19F24800BDBE08 /* TOTPGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TOTPGenerator.swift; sourceTree = ""; }; - 6541341B2E1DC28000BDBE08 /* DateExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateExtensions.swift; sourceTree = ""; }; 654134172E1DC09700BDBE08 /* OverridePresetsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverridePresetsView.swift; sourceTree = ""; }; 654134192E1DC27900BDBE08 /* OverridePresetData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverridePresetData.swift; sourceTree = ""; }; + 6541341B2E1DC28000BDBE08 /* DateExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateExtensions.swift; sourceTree = ""; }; + 656F8C0F2E49F36F0008DC1D /* QRCodeDisplayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeDisplayView.swift; sourceTree = ""; }; + 656F8C112E49F3780008DC1D /* QRCodeGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeGenerator.swift; sourceTree = ""; }; + 656F8C132E49F3D20008DC1D /* RemoteCommandSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteCommandSettings.swift; sourceTree = ""; }; + 65E153C22E4BB69100693A4F /* URLTokenValidationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLTokenValidationView.swift; sourceTree = ""; }; A7D55B42A22051DAD69E89D0 /* Pods_LoopFollow.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_LoopFollow.framework; sourceTree = BUILT_PRODUCTS_DIR; }; DD0247582DB2E89600FCADF6 /* AlarmCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmCondition.swift; sourceTree = ""; }; DD02475B2DB2E8FB00FCADF6 /* BuildExpireCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuildExpireCondition.swift; sourceTree = ""; }; @@ -910,6 +918,8 @@ DD4878062C7B2E9E0048F05C /* Settings */ = { isa = PBXGroup; children = ( + 65E153C22E4BB69100693A4F /* URLTokenValidationView.swift */, + 656F8C132E49F3D20008DC1D /* RemoteCommandSettings.swift */, DD4878072C7B30BF0048F05C /* RemoteSettingsView.swift */, DD4878092C7B30D40048F05C /* RemoteSettingsViewModel.swift */, ); @@ -1213,6 +1223,7 @@ DDF6999C2C5AAA4C0058A8D9 /* Views */ = { isa = PBXGroup; children = ( + 656F8C0F2E49F36F0008DC1D /* QRCodeDisplayView.swift */, DDE75D2C2DE71401007C1FC1 /* TogglableSecureInput.swift */, DDE75D222DE5E505007C1FC1 /* Glyph.swift */, DD8316492DE4C504004467AA /* SettingsStepperRow.swift */, @@ -1481,6 +1492,7 @@ FCC688542489367300A0279D /* Helpers */ = { isa = PBXGroup; children = ( + 656F8C112E49F3780008DC1D /* QRCodeGenerator.swift */, DD1D52B82E1EB5DC00432050 /* TabPosition.swift */, DD83164B2DE4DB3A004467AA /* BinaryFloatingPoint+localized.swift */, DD4AFB3A2DB55CB600BB593F /* TimeOfDay.swift */, @@ -1871,10 +1883,12 @@ DDF6999B2C5AA32E0058A8D9 /* TempTargetPreset.swift in Sources */, DD7F4C0F2DD51EC200D449E9 /* TempTargetStartCondition.swift in Sources */, DDBD19962DFB44B0005C2D69 /* Alarm+byPriorityThenSpec.swift in Sources */, + 656F8C102E49F36F0008DC1D /* QRCodeDisplayView.swift in Sources */, DDC6CA3D2DD7C6090060EE25 /* TemporaryCondition.swift in Sources */, DD9ACA0E2D340BFF00415D8A /* AlarmTask.swift in Sources */, DDCF9A822D85FD15004DF4DD /* AlarmType.swift in Sources */, DD7FFAFD2A72953000C3A304 /* EKEventStore+Extensions.swift in Sources */, + 656F8C122E49F3780008DC1D /* QRCodeGenerator.swift in Sources */, 6541341A2E1DC27900BDBE08 /* OverridePresetData.swift in Sources */, FCC6886724898F8000A0279D /* UserDefaultsValue.swift in Sources */, DD7F4C092DD504A700D449E9 /* OverrideStartCondition.swift in Sources */, @@ -1891,6 +1905,7 @@ DD7F4C072DD5042F00D449E9 /* OverrideStartAlarmEditor.swift in Sources */, DDCC3A4B2DDBB5E4006F1C10 /* BatteryCondition.swift in Sources */, DDDF6F492D479AF000884336 /* NoRemoteView.swift in Sources */, + 656F8C142E49F3D20008DC1D /* RemoteCommandSettings.swift in Sources */, DD12D4872E1705E6004E0112 /* AlarmsContainerView.swift in Sources */, DD83164A2DE4C504004467AA /* SettingsStepperRow.swift in Sources */, DD0650ED2DCE9371004D3B41 /* HighBgAlarmEditor.swift in Sources */, @@ -2041,6 +2056,7 @@ DD0650EF2DCE96FF004D3B41 /* HighBGCondition.swift in Sources */, DDC6CA472DD8D9010060EE25 /* PumpChangeAlarmEditor.swift in Sources */, DD4878132C7B750D0048F05C /* TempTargetView.swift in Sources */, + 65E153C32E4BB69100693A4F /* URLTokenValidationView.swift in Sources */, DD0C0C682C48529400DBADDF /* Metric.swift in Sources */, FCC6886D2489909D00A0279D /* AnyConvertible.swift in Sources */, DDC6CA432DD8CED20060EE25 /* SensorAgeCondition.swift in Sources */, diff --git a/LoopFollow/Helpers/QRCodeGenerator.swift b/LoopFollow/Helpers/QRCodeGenerator.swift new file mode 100644 index 000000000..374a811a8 --- /dev/null +++ b/LoopFollow/Helpers/QRCodeGenerator.swift @@ -0,0 +1,95 @@ +// LoopFollow +// QRCodeGenerator.swift +// Created by codebymini. + +import CoreImage +import UIKit + +enum QRCodeGenerator { + /// Generates a QR code image from a string + /// - Parameters: + /// - string: The string to encode in the QR code + /// - size: The size of the generated image (default: 200x200) + /// - correctionLevel: The error correction level (default: .M) + /// - Returns: A UIImage containing the QR code, or nil if generation fails + static func generateQRCode( + from string: String, + size: CGSize = CGSize(width: 200, height: 200), + correctionLevel: String = "M" + ) -> UIImage? { + // Create a CIFilter for QR code generation + guard let filter = CIFilter(name: "CIQRCodeGenerator") else { + return nil + } + + // Set the input data (the string to encode) + let data = string.data(using: .utf8) + filter.setValue(data, forKey: "inputMessage") + + // Set the error correction level + filter.setValue(correctionLevel, forKey: "inputCorrectionLevel") + + // Get the output image + guard let outputImage = filter.outputImage else { + return nil + } + + // Scale the image to the desired size + let scaleX = size.width / outputImage.extent.size.width + let scaleY = size.height / outputImage.extent.size.height + let scaledImage = outputImage.transformed(by: CGAffineTransform(scaleX: scaleX, y: scaleY)) + + // Convert CIImage to UIImage + let context = CIContext() + guard let cgImage = context.createCGImage(scaledImage, from: scaledImage.extent) else { + return nil + } + + return UIImage(cgImage: cgImage) + } + + /// Generates a QR code image with custom colors + /// - Parameters: + /// - string: The string to encode in the QR code + /// - size: The size of the generated image (default: 200x200) + /// - foregroundColor: The color of the QR code (default: black) + /// - backgroundColor: The background color (default: white) + /// - correctionLevel: The error correction level (default: .M) + /// - Returns: A UIImage containing the QR code, or nil if generation fails + static func generateQRCode( + from string: String, + size: CGSize = CGSize(width: 200, height: 200), + foregroundColor: UIColor = .black, + backgroundColor: UIColor = .white, + correctionLevel: String = "M" + ) -> UIImage? { + // First generate the basic QR code + guard let qrCodeImage = generateQRCode(from: string, size: size, correctionLevel: correctionLevel) else { + return nil + } + + // Create a new image context with the desired size + UIGraphicsBeginImageContextWithOptions(size, false, 0) + defer { UIGraphicsEndImageContext() } + + guard let context = UIGraphicsGetCurrentContext() else { + return nil + } + + // Fill the background + backgroundColor.setFill() + context.fill(CGRect(origin: .zero, size: size)) + + // Draw the QR code with the foreground color + context.setFillColor(foregroundColor.cgColor) + context.setBlendMode(.sourceIn) + + // Create a mask from the original QR code + if let cgImage = qrCodeImage.cgImage { + let maskImage = UIImage(cgImage: cgImage) + maskImage.draw(in: CGRect(origin: .zero, size: size)) + } + + return UIGraphicsGetImageFromCurrentImageContext() + } +} diff --git a/LoopFollow/Helpers/Views/QRCodeDisplayView.swift b/LoopFollow/Helpers/Views/QRCodeDisplayView.swift new file mode 100644 index 000000000..15c5df28f --- /dev/null +++ b/LoopFollow/Helpers/Views/QRCodeDisplayView.swift @@ -0,0 +1,77 @@ +// LoopFollow +// QRCodeDisplayView.swift +// Created by codebymini. + +import SwiftUI +import UIKit + +struct QRCodeDisplayView: View { + let qrCodeString: String + let size: CGSize + let foregroundColor: UIColor + let backgroundColor: UIColor + + @State private var qrCodeImage: UIImage? + + init( + qrCodeString: String, + size: CGSize = CGSize(width: 250, height: 250), + foregroundColor: UIColor = .black, + backgroundColor: UIColor = .white + ) { + self.qrCodeString = qrCodeString + self.size = size + self.foregroundColor = foregroundColor + self.backgroundColor = backgroundColor + } + + var body: some View { + VStack(spacing: 16) { + if let qrCodeImage = qrCodeImage { + Image(uiImage: qrCodeImage) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: size.width, height: size.height) + .cornerRadius(12) + .shadow(radius: 4) + } else { + RoundedRectangle(cornerRadius: 12) + .fill(Color.gray.opacity(0.3)) + .frame(width: size.width, height: size.height) + .overlay( + ProgressView() + .scaleEffect(1.5) + ) + } + + Text("Scan this QR code with another LoopFollow app to import remote command settings") + .font(.caption) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 20) + } + .onAppear { + generateQRCode() + } + } + + private func generateQRCode() { + DispatchQueue.global(qos: .userInitiated).async { + let image = QRCodeGenerator.generateQRCode( + from: qrCodeString, + size: size, + foregroundColor: foregroundColor, + backgroundColor: backgroundColor + ) + + DispatchQueue.main.async { + self.qrCodeImage = image + } + } + } +} + +#Preview { + QRCodeDisplayView(qrCodeString: "https://example.com/test") + .padding() +} diff --git a/LoopFollow/Log/LogManager.swift b/LoopFollow/Log/LogManager.swift index 7608cd4c8..77d76314b 100644 --- a/LoopFollow/Log/LogManager.swift +++ b/LoopFollow/Log/LogManager.swift @@ -28,6 +28,7 @@ class LogManager { case alarm = "Alarm" case calendar = "Calendar" case deviceStatus = "Device Status" + case remote = "Remote" } init() { diff --git a/LoopFollow/Remote/Settings/RemoteCommandSettings.swift b/LoopFollow/Remote/Settings/RemoteCommandSettings.swift new file mode 100644 index 000000000..14844283b --- /dev/null +++ b/LoopFollow/Remote/Settings/RemoteCommandSettings.swift @@ -0,0 +1,217 @@ +// LoopFollow +// RemoteCommandSettings.swift +// Created by codebymini. + +import Foundation +import HealthKit + +struct RemoteCommandSettings: Codable { + let remoteType: RemoteType + let user: String + let sharedSecret: String + let apnsKey: String + let keyId: String + let teamId: String? + let maxBolus: Double + let maxCarbs: Double + let maxProtein: Double + let maxFat: Double + let mealWithBolus: Bool + let mealWithFatProtein: Bool + let productionEnvironment: Bool + let loopAPNSQrCodeURL: String + let url: String + let token: String + let version: String + + init( + remoteType: RemoteType, + user: String, + sharedSecret: String, + apnsKey: String, + keyId: String, + teamId: String?, + maxBolus: Double, + maxCarbs: Double, + maxProtein: Double, + maxFat: Double, + mealWithBolus: Bool, + mealWithFatProtein: Bool, + productionEnvironment: Bool, + loopAPNSQrCodeURL: String, + url: String, + token: String + ) { + self.remoteType = remoteType + self.user = user + self.sharedSecret = sharedSecret + self.apnsKey = apnsKey + self.keyId = keyId + self.teamId = teamId + self.maxBolus = maxBolus + self.maxCarbs = maxCarbs + self.maxProtein = maxProtein + self.maxFat = maxFat + self.mealWithBolus = mealWithBolus + self.mealWithFatProtein = mealWithFatProtein + self.productionEnvironment = productionEnvironment + self.loopAPNSQrCodeURL = loopAPNSQrCodeURL + self.url = url + self.token = token + version = "1.0" + } + + /// Creates RemoteCommandSettings from the current Storage values + static func fromCurrentStorage() -> RemoteCommandSettings { + let storage = Storage.shared + + return RemoteCommandSettings( + remoteType: storage.remoteType.value, + user: storage.user.value, + sharedSecret: storage.sharedSecret.value, + apnsKey: storage.apnsKey.value, + keyId: storage.keyId.value, + teamId: storage.teamId.value, + maxBolus: storage.maxBolus.value.doubleValue(for: .internationalUnit()), + maxCarbs: storage.maxCarbs.value.doubleValue(for: .gram()), + maxProtein: storage.maxProtein.value.doubleValue(for: .gram()), + maxFat: storage.maxFat.value.doubleValue(for: .gram()), + mealWithBolus: storage.mealWithBolus.value, + mealWithFatProtein: storage.mealWithFatProtein.value, + productionEnvironment: storage.productionEnvironment.value, + loopAPNSQrCodeURL: storage.loopAPNSQrCodeURL.value, + url: storage.url.value, + token: storage.token.value + ) + } + + /// Applies the settings to the current Storage + func applyToStorage() { + let storage = Storage.shared + + storage.remoteType.value = remoteType + storage.user.value = user + storage.sharedSecret.value = sharedSecret + storage.apnsKey.value = apnsKey + storage.keyId.value = keyId + storage.teamId.value = teamId + storage.maxBolus.value = HKQuantity(unit: .internationalUnit(), doubleValue: maxBolus) + storage.maxCarbs.value = HKQuantity(unit: .gram(), doubleValue: maxCarbs) + storage.maxProtein.value = HKQuantity(unit: .gram(), doubleValue: maxProtein) + storage.maxFat.value = HKQuantity(unit: .gram(), doubleValue: maxFat) + storage.mealWithBolus.value = mealWithBolus + storage.mealWithFatProtein.value = mealWithFatProtein + storage.productionEnvironment.value = productionEnvironment + storage.loopAPNSQrCodeURL.value = loopAPNSQrCodeURL + storage.url.value = url + storage.token.value = token + + // Automatically set device type based on remote type + switch remoteType { + case .loopAPNS: + storage.device.value = "Loop" + case .trc: + storage.device.value = "Trio" + case .nightscout: + // For Nightscout, we don't automatically set device type + // as it should be determined by the actual connection + break + case .none: + // For none, we don't change the device type + break + } + } + + /// Encodes the settings to a JSON string for QR code generation + func encodeToJSON() -> String? { + do { + let data = try JSONEncoder().encode(self) + return String(data: data, encoding: .utf8) + } catch { + return nil + } + } + + /// Decodes settings from a JSON string + static func decodeFromJSON(_ jsonString: String) -> RemoteCommandSettings? { + guard let data = jsonString.data(using: .utf8) else { + return nil + } + + do { + return try JSONDecoder().decode(RemoteCommandSettings.self, from: data) + } catch { + return nil + } + } + + /// Checks if the settings are valid for the given remote type + func isValid() -> Bool { + switch remoteType { + case .none: + return true + case .nightscout: + return !user.isEmpty + case .trc: + return !user.isEmpty && !sharedSecret.isEmpty && !apnsKey.isEmpty && !keyId.isEmpty + case .loopAPNS: + return !keyId.isEmpty && !apnsKey.isEmpty && teamId != nil && !loopAPNSQrCodeURL.isEmpty + } + } + + /// Validates URL and token compatibility with current storage + /// Returns a tuple with (isCompatible, shouldPromptForURL, shouldPromptForToken, message) + func validateCompatibilityWithCurrentStorage() -> (isCompatible: Bool, shouldPromptForURL: Bool, shouldPromptForToken: Bool, message: String) { + let storage = Storage.shared + let currentURL = storage.url.value + let currentToken = storage.token.value + + var shouldPromptForURL = false + var shouldPromptForToken = false + var message = "" + + // Check if current user has URL set + let hasCurrentURL = !currentURL.isEmpty + let hasCurrentToken = !currentToken.isEmpty + + // Check if scanned settings have URL/token + let hasScannedURL = !url.isEmpty + let hasScannedToken = !token.isEmpty + + // If current user doesn't have URL but scanned settings do, prompt to set it + if !hasCurrentURL, hasScannedURL { + shouldPromptForURL = true + message = "The scanned settings include a Nightscout URL. Would you like to set this as your Nightscout address?" + } + + // If current user doesn't have token but scanned settings do, prompt to set it + if !hasCurrentToken, hasScannedToken { + shouldPromptForToken = true + if !message.isEmpty { + message += "\n\nThe scanned settings also include a token. Would you like to set this as your access token?" + } else { + message = "The scanned settings include a token. Would you like to set this as your access token?" + } + } + + // If both have URLs but they don't match, show warning + if hasCurrentURL, hasScannedURL, currentURL != url { + shouldPromptForURL = true + message = "The scanned Nightscout URL (\(url)) doesn't match your current Nightscout address (\(currentURL)). Would you like to change your Nightscout address to match the scanned settings?" + } + + // If both have tokens but they don't match, show warning + if hasCurrentToken, hasScannedToken, currentToken != token { + shouldPromptForToken = true + if !message.isEmpty { + message += "\n\nThe scanned token doesn't match your current access token. Would you like to update your token?" + } else { + message = "The scanned token doesn't match your current access token. Would you like to update your token?" + } + } + + let isCompatible = !shouldPromptForURL && !shouldPromptForToken + + return (isCompatible, shouldPromptForURL, shouldPromptForToken, message) + } +} diff --git a/LoopFollow/Remote/Settings/RemoteSettingsView.swift b/LoopFollow/Remote/Settings/RemoteSettingsView.swift index f3be9bf51..62ab989c7 100644 --- a/LoopFollow/Remote/Settings/RemoteSettingsView.swift +++ b/LoopFollow/Remote/Settings/RemoteSettingsView.swift @@ -12,12 +12,16 @@ struct RemoteSettingsView: View { @State private var showAlert: Bool = false @State private var alertType: AlertType? = nil @State private var alertMessage: String? = nil + @State private var otpTimeRemaining: Int? = nil private let otpPeriod: TimeInterval = 30 private var otpTimer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() enum AlertType { case validation + case qrCodeError + case urlTokenValidation + case urlTokenUpdate } init(viewModel: RemoteSettingsViewModel) { @@ -58,6 +62,36 @@ struct RemoteSettingsView: View { .foregroundColor(.secondary) } + // MARK: - QR Code Sharing Section + + Section { + if viewModel.remoteType == .none { + Button(action: { + viewModel.isShowingQRCodeScanner = true + }) { + HStack { + Image(systemName: "qrcode.viewfinder") + Text("Import Remote Settings from QR Code") + } + } + .buttonStyle(.borderedProminent) + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + } else { + Button(action: { + viewModel.isShowingQRCodeDisplay = true + }) { + HStack { + Image(systemName: "qrcode") + Text("Export Remote Settings as QR Code") + } + } + .buttonStyle(.borderedProminent) + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + } + } + // MARK: - Meal Section (for TRC only) if viewModel.remoteType == .trc { @@ -238,6 +272,28 @@ struct RemoteSettingsView: View { message: Text(alertMessage ?? "Invalid input."), dismissButton: .default(Text("OK")) ) + case .qrCodeError: + return Alert( + title: Text("QR Code Error"), + message: Text(alertMessage ?? "An error occurred while processing the QR code."), + dismissButton: .default(Text("OK")) + ) + case .urlTokenValidation: + return Alert( + title: Text("URL/Token Validation"), + message: Text(viewModel.validationMessage), + dismissButton: .default(Text("OK")) { + viewModel.showURLTokenValidation = false + } + ) + case .urlTokenUpdate: + return Alert( + title: Text("URL/Token Update"), + message: Text(viewModel.validationMessage), + dismissButton: .default(Text("OK")) { + viewModel.showURLTokenValidation = false + } + ) case .none: return Alert(title: Text("Unknown Alert")) } @@ -247,14 +303,75 @@ struct RemoteSettingsView: View { viewModel.handleLoopAPNSQRCodeScanResult(result) } } + .sheet(isPresented: $viewModel.isShowingQRCodeScanner) { + SimpleQRCodeScannerView { result in + viewModel.handleRemoteCommandQRCodeScanResult(result) + } + } + .sheet(isPresented: $viewModel.isShowingQRCodeDisplay) { + NavigationView { + VStack { + if let qrCodeString = viewModel.generateQRCodeForCurrentSettings() { + QRCodeDisplayView(qrCodeString: qrCodeString) + .padding() + } else { + Text("Failed to generate QR code") + .foregroundColor(.red) + .padding() + } + } + .navigationTitle("Share Remote Settings") + .navigationBarTitleDisplayMode(.inline) + .navigationBarItems(trailing: Button("Done") { + viewModel.isShowingQRCodeDisplay = false + }) + } + } + .sheet(isPresented: $viewModel.showURLTokenValidation) { + NavigationView { + URLTokenValidationView( + settings: viewModel.pendingSettings!, + shouldPromptForURL: viewModel.shouldPromptForURL, + shouldPromptForToken: viewModel.shouldPromptForToken, + message: viewModel.validationMessage, + onConfirm: { confirmedSettings in + confirmedSettings.applyToStorage() + viewModel.updateViewModelFromStorage() + viewModel.showURLTokenValidation = false + viewModel.pendingSettings = nil + LogManager.shared.log(category: .remote, message: "Remote command settings imported from QR code with URL/token updates") + }, + onCancel: { + viewModel.showURLTokenValidation = false + viewModel.pendingSettings = nil + } + ) + } + } .onAppear { // Reset timer state so it shows '-' until first tick otpTimeRemaining = nil + // Update view model from storage to ensure UI is current + viewModel.updateViewModelFromStorage() } .onReceive(otpTimer) { _ in let now = Date().timeIntervalSince1970 otpTimeRemaining = Int(otpPeriod - (now.truncatingRemainder(dividingBy: otpPeriod))) } + .onReceive(viewModel.$qrCodeErrorMessage) { errorMessage in + if let errorMessage = errorMessage, !errorMessage.isEmpty { + handleQRCodeError(errorMessage) + // Clear the error message after showing the alert + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + viewModel.qrCodeErrorMessage = nil + } + } + } + .onReceive(viewModel.$showURLTokenValidation) { showValidation in + if showValidation { + // The sheet will be shown automatically due to the binding + } + } .preferredColorScheme(Storage.shared.forceDarkMode.value ? .dark : nil) .navigationTitle("Remote Settings") .navigationBarTitleDisplayMode(.inline) @@ -290,6 +407,14 @@ struct RemoteSettingsView: View { showAlert = true } + // MARK: - QR Code Error Handler + + private func handleQRCodeError(_ message: String) { + alertMessage = message + alertType = .qrCodeError + showAlert = true + } + private var guardrailsSection: some View { Section(header: Text("Guardrails")) { HStack { diff --git a/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift b/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift index e47b353ba..2120d0bdd 100644 --- a/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift +++ b/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift @@ -30,6 +30,20 @@ class RemoteSettingsViewModel: ObservableObject { @Published var isShowingLoopAPNSScanner: Bool = false @Published var loopAPNSErrorMessage: String? + // MARK: - QR Code Sharing Properties + + @Published var isShowingQRCodeScanner: Bool = false + @Published var isShowingQRCodeDisplay: Bool = false + @Published var qrCodeErrorMessage: String? + + // MARK: - URL/Token Validation Properties + + @Published var pendingSettings: RemoteCommandSettings? + @Published var showURLTokenValidation: Bool = false + @Published var validationMessage: String = "" + @Published var shouldPromptForURL: Bool = false + @Published var shouldPromptForToken: Bool = false + // MARK: - Computed property for Loop APNS Setup validation var loopAPNSSetup: Bool { @@ -158,6 +172,13 @@ class RemoteSettingsViewModel: ObservableObject { switch result { case let .success(code): self.loopAPNSQrCodeURL = code + // Set device type and remote type for Loop APNS + Storage.shared.device.value = "Loop" + Storage.shared.remoteType.value = .loopAPNS + // Update view model properties + self.remoteType = .loopAPNS + self.isLoopDevice = true + self.isTrioDevice = false LogManager.shared.log(category: .apns, message: "Loop APNS QR code scanned: \(code)") case let .failure(error): self.loopAPNSErrorMessage = "Scanning failed: \(error.localizedDescription)" @@ -165,4 +186,71 @@ class RemoteSettingsViewModel: ObservableObject { self.isShowingLoopAPNSScanner = false } } + + // MARK: - QR Code Sharing Methods + + func handleRemoteCommandQRCodeScanResult(_ result: Result) { + DispatchQueue.main.async { + switch result { + case let .success(jsonString): + if let settings = RemoteCommandSettings.decodeFromJSON(jsonString) { + if settings.isValid() { + // Check URL and token compatibility + let validation = settings.validateCompatibilityWithCurrentStorage() + + if validation.isCompatible { + // No conflicts, apply settings directly + settings.applyToStorage() + self.updateViewModelFromStorage() + LogManager.shared.log(category: .remote, message: "Remote command settings imported from QR code") + } else { + // Conflicts detected, show validation view + self.pendingSettings = settings + self.validationMessage = validation.message + self.shouldPromptForURL = validation.shouldPromptForURL + self.shouldPromptForToken = validation.shouldPromptForToken + self.showURLTokenValidation = true + } + } else { + self.qrCodeErrorMessage = "Invalid remote command settings in QR code" + } + } else { + self.qrCodeErrorMessage = "Failed to decode remote command settings from QR code" + } + case let .failure(error): + self.qrCodeErrorMessage = "Scanning failed: \(error.localizedDescription)" + } + self.isShowingQRCodeScanner = false + } + } + + func generateQRCodeForCurrentSettings() -> String? { + let settings = RemoteCommandSettings.fromCurrentStorage() + return settings.encodeToJSON() + } + + // MARK: - Public Methods for View Access + + /// Updates the view model properties from storage (accessible from view) + func updateViewModelFromStorage() { + let storage = Storage.shared + remoteType = storage.remoteType.value + user = storage.user.value + sharedSecret = storage.sharedSecret.value + apnsKey = storage.apnsKey.value + keyId = storage.keyId.value + maxBolus = storage.maxBolus.value + maxCarbs = storage.maxCarbs.value + maxProtein = storage.maxProtein.value + maxFat = storage.maxFat.value + mealWithBolus = storage.mealWithBolus.value + mealWithFatProtein = storage.mealWithFatProtein.value + loopDeveloperTeamId = storage.teamId.value ?? "" + loopAPNSQrCodeURL = storage.loopAPNSQrCodeURL.value + productionEnvironment = storage.productionEnvironment.value + + // Update device-related properties + isTrioDevice = (storage.device.value == "Trio") + isLoopDevice = (storage.device.value == "Loop") + } } diff --git a/LoopFollow/Remote/Settings/URLTokenValidationView.swift b/LoopFollow/Remote/Settings/URLTokenValidationView.swift new file mode 100644 index 000000000..64c6d1f15 --- /dev/null +++ b/LoopFollow/Remote/Settings/URLTokenValidationView.swift @@ -0,0 +1,99 @@ +// LoopFollow +// URLTokenValidationView.swift +// Created by codebymini. + +import SwiftUI + +struct URLTokenValidationView: View { + let settings: RemoteCommandSettings + let shouldPromptForURL: Bool + let shouldPromptForToken: Bool + let message: String + let onConfirm: (RemoteCommandSettings) -> Void + let onCancel: () -> Void + + init( + settings: RemoteCommandSettings, + shouldPromptForURL: Bool, + shouldPromptForToken: Bool, + message: String, + onConfirm: @escaping (RemoteCommandSettings) -> Void, + onCancel: @escaping () -> Void + ) { + self.settings = settings + self.shouldPromptForURL = shouldPromptForURL + self.shouldPromptForToken = shouldPromptForToken + self.message = message + self.onConfirm = onConfirm + self.onCancel = onCancel + } + + var body: some View { + Form { + Section { + Text(message) + .font(.body) + .foregroundColor(.primary) + } + + // Show URL section if we have URL data + if !settings.url.isEmpty { + Section(header: Text("Nightscout URL")) { + HStack { + Text("Current URL:") + Spacer() + Text(Storage.shared.url.value.isEmpty ? "Not set" : Storage.shared.url.value) + .foregroundColor(.secondary) + } + + HStack { + Text("Scanned URL:") + Spacer() + Text(settings.url) + .foregroundColor(.primary) + } + } + } + + // Show token section if we have token data + if !settings.token.isEmpty { + Section(header: Text("Access Token")) { + HStack { + Text("Current Token:") + Spacer() + Text(Storage.shared.token.value.isEmpty ? "Not set" : "••••••••") + .foregroundColor(.secondary) + } + + HStack { + Text("Scanned Token:") + Spacer() + Text("••••••••") + .foregroundColor(.primary) + } + } + } + + Section { + HStack { + Button("Cancel") { + onCancel() + } + .buttonStyle(.bordered) + + Spacer() + + Button("Confirm & Import") { + onConfirm(settings) + } + .buttonStyle(.borderedProminent) + } + } + } + .navigationTitle("URL/Token Validation") + .navigationBarTitleDisplayMode(.inline) + .navigationBarItems(trailing: Button("Cancel") { + onCancel() + }) + } +} diff --git a/LoopFollow/Settings/SettingsMenuView.swift b/LoopFollow/Settings/SettingsMenuView.swift index 663669ccd..63d54ff71 100644 --- a/LoopFollow/Settings/SettingsMenuView.swift +++ b/LoopFollow/Settings/SettingsMenuView.swift @@ -67,6 +67,12 @@ struct SettingsMenuView: View { { settingsPath.value.append(Sheet.remote) } + } else { + NavigationRow(title: "Import Settings", + icon: "square.and.arrow.down") + { + settingsPath.value.append(Sheet.remote) + } } }