Просмотр исходного кода

Add sharing remote settings with QR code (#452)

Daniel Mini Johansson 10 месяцев назад
Родитель
Сommit
a8d78f237c

+ 16 - 0
LoopFollow.xcodeproj/project.pbxproj

@@ -13,6 +13,10 @@
 		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 */; };
 		6584B1012E4A263900135D4D /* TOTPService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6584B1002E4A263900135D4D /* TOTPService.swift */; };
 		65E8A2862E44B0300065037B /* VolumeButtonHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E8A2852E44B0300065037B /* VolumeButtonHandler.swift */; };
 		DD0247592DB2E89600FCADF6 /* AlarmCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0247582DB2E89600FCADF6 /* AlarmCondition.swift */; };
@@ -399,6 +403,10 @@
 		654134172E1DC09700BDBE08 /* OverridePresetsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverridePresetsView.swift; sourceTree = "<group>"; };
 		654134192E1DC27900BDBE08 /* OverridePresetData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverridePresetData.swift; sourceTree = "<group>"; };
 		6541341B2E1DC28000BDBE08 /* DateExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateExtensions.swift; sourceTree = "<group>"; };
+		656F8C0F2E49F36F0008DC1D /* QRCodeDisplayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeDisplayView.swift; sourceTree = "<group>"; };
+		656F8C112E49F3780008DC1D /* QRCodeGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeGenerator.swift; sourceTree = "<group>"; };
+		656F8C132E49F3D20008DC1D /* RemoteCommandSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteCommandSettings.swift; sourceTree = "<group>"; };
+		65E153C22E4BB69100693A4F /* URLTokenValidationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLTokenValidationView.swift; sourceTree = "<group>"; };
 		6584B1002E4A263900135D4D /* TOTPService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TOTPService.swift; sourceTree = "<group>"; };
 		65E8A2852E44B0300065037B /* VolumeButtonHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VolumeButtonHandler.swift; sourceTree = "<group>"; };
 		A7D55B42A22051DAD69E89D0 /* Pods_LoopFollow.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_LoopFollow.framework; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -916,6 +924,8 @@
 		DD4878062C7B2E9E0048F05C /* Settings */ = {
 			isa = PBXGroup;
 			children = (
+				65E153C22E4BB69100693A4F /* URLTokenValidationView.swift */,
+				656F8C132E49F3D20008DC1D /* RemoteCommandSettings.swift */,
 				DD4878072C7B30BF0048F05C /* RemoteSettingsView.swift */,
 				DD4878092C7B30D40048F05C /* RemoteSettingsViewModel.swift */,
 			);
@@ -1220,6 +1230,7 @@
 		DDF6999C2C5AAA4C0058A8D9 /* Views */ = {
 			isa = PBXGroup;
 			children = (
+				656F8C0F2E49F36F0008DC1D /* QRCodeDisplayView.swift */,
 				DDE75D2C2DE71401007C1FC1 /* TogglableSecureInput.swift */,
 				DDE75D222DE5E505007C1FC1 /* Glyph.swift */,
 				DD8316492DE4C504004467AA /* SettingsStepperRow.swift */,
@@ -1489,6 +1500,7 @@
 		FCC688542489367300A0279D /* Helpers */ = {
 			isa = PBXGroup;
 			children = (
+				656F8C112E49F3780008DC1D /* QRCodeGenerator.swift */,
 				DD4A407D2E6AFEE6007B318B /* AuthService.swift */,
 				DD1D52B82E1EB5DC00432050 /* TabPosition.swift */,
 				DD83164B2DE4DB3A004467AA /* BinaryFloatingPoint+localized.swift */,
@@ -1880,10 +1892,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 */,
@@ -1900,6 +1914,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 */,
@@ -2053,6 +2068,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 */,

+ 95 - 0
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()
+    }
+}

+ 77 - 0
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()
+}

+ 1 - 0
LoopFollow/Log/LogManager.swift

@@ -28,6 +28,7 @@ class LogManager {
         case volumeButtonSnooze = "Volume Button Snooze"
         case calendar = "Calendar"
         case deviceStatus = "Device Status"
+        case remote = "Remote"
     }
 
     init() {

+ 217 - 0
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)
+    }
+}

+ 125 - 0
LoopFollow/Remote/Settings/RemoteSettingsView.swift

@@ -11,12 +11,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) {
@@ -57,6 +61,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 {
@@ -237,6 +271,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"))
             }
@@ -246,14 +302,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)
@@ -289,6 +406,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 {

+ 88 - 0
LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift

@@ -29,6 +29,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 {
@@ -157,6 +171,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)"
@@ -164,4 +185,71 @@ class RemoteSettingsViewModel: ObservableObject {
             self.isShowingLoopAPNSScanner = false
         }
     }
+
+    // MARK: - QR Code Sharing Methods
+
+    func handleRemoteCommandQRCodeScanResult(_ result: Result<String, Error>) {
+        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")
+    }
 }

+ 99 - 0
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()
+        })
+    }
+}

+ 6 - 0
LoopFollow/Settings/SettingsMenuView.swift

@@ -66,6 +66,12 @@ struct SettingsMenuView: View {
                         {
                             settingsPath.value.append(Sheet.remote)
                         }
+                    } else {
+                        NavigationRow(title: "Import Settings",
+                                      icon: "square.and.arrow.down")
+                        {
+                            settingsPath.value.append(Sheet.remote)
+                        }
                     }
                 }