diff --git a/Localizable.xcstrings b/Localizable.xcstrings index d1865493..3183a0d8 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -9665,6 +9665,12 @@ } } } + }, + "Disconnect Node" : { + + }, + "Disconnect the currently connected node" : { + }, "Dismiss" : { "localizations" : { diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index ed258668..6271830c 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -65,7 +65,8 @@ BCB613832C672A2600485544 /* MessageChannelIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCB613822C672A2600485544 /* MessageChannelIntent.swift */; }; BCB613852C68703800485544 /* NodePositionIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCB613842C68703800485544 /* NodePositionIntent.swift */; }; BCB613872C69A0FB00485544 /* AppIntentErrors.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCB613862C69A0FB00485544 /* AppIntentErrors.swift */; }; - BCDDFA9A2DBB180D0065189C /* ScrollToBottomButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCDDFA992DBB180D0065189C /* ScrollToBottomButton.swift */; }; + BCD93CBA2D9E11A2006C9214 /* DisconnectNodeIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCD93CB92D9E11A2006C9214 /* DisconnectNodeIntent.swift */; }; + BCDDFA9A2DBB180D0065189C /* ScrollToBottomButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCDDFA992DBB180D0065189C /* ScrollToBottomButton.swift */; }; BCE2D3C32C7ADF42008E6199 /* ShutDownNodeIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCE2D3C22C7ADF42008E6199 /* ShutDownNodeIntent.swift */; }; BCE2D3C52C7AE369008E6199 /* RestartNodeIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCE2D3C42C7AE369008E6199 /* RestartNodeIntent.swift */; }; BCE2D3C72C7B0D0A008E6199 /* ShortcutsProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCE2D3C62C7B0D0A008E6199 /* ShortcutsProvider.swift */; }; @@ -331,7 +332,8 @@ BCB613822C672A2600485544 /* MessageChannelIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageChannelIntent.swift; sourceTree = ""; }; BCB613842C68703800485544 /* NodePositionIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodePositionIntent.swift; sourceTree = ""; }; BCB613862C69A0FB00485544 /* AppIntentErrors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIntentErrors.swift; sourceTree = ""; }; - BCDDFA992DBB180D0065189C /* ScrollToBottomButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollToBottomButton.swift; sourceTree = ""; }; + BCD93CB92D9E11A2006C9214 /* DisconnectNodeIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisconnectNodeIntent.swift; sourceTree = ""; }; + BCDDFA992DBB180D0065189C /* ScrollToBottomButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollToBottomButton.swift; sourceTree = ""; }; BCE2D3C22C7ADF42008E6199 /* ShutDownNodeIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShutDownNodeIntent.swift; sourceTree = ""; }; BCE2D3C42C7AE369008E6199 /* RestartNodeIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestartNodeIntent.swift; sourceTree = ""; }; BCE2D3C62C7B0D0A008E6199 /* ShortcutsProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutsProvider.swift; sourceTree = ""; }; @@ -691,6 +693,7 @@ BCE2D3C62C7B0D0A008E6199 /* ShortcutsProvider.swift */, BC6B45FE2CB2F98900723CEB /* SaveChannelSettingsIntent.swift */, BC47C2EE2CE0017D008245CA /* MessageNodeIntent.swift */, + BCD93CB92D9E11A2006C9214 /* DisconnectNodeIntent.swift */, ); path = AppIntents; sourceTree = ""; @@ -1418,6 +1421,7 @@ DDC94FCE29CF55310082EA6E /* RtttlConfig.swift in Sources */, DD964FBD296E6B01007C176F /* EmojiOnlyTextField.swift in Sources */, DD8169FF272476C700F4AB02 /* LogDocument.swift in Sources */, + BCD93CBA2D9E11A2006C9214 /* DisconnectNodeIntent.swift in Sources */, DDC94FC129CE063B0082EA6E /* BatteryLevel.swift in Sources */, 231B3F252D087C3C0069A07D /* EnvironmentDefaultColumns.swift in Sources */, 25F5D5BE2C3F6D87008036E3 /* NavigationState.swift in Sources */, diff --git a/Meshtastic/AppIntents/DisconnectNodeIntent.swift b/Meshtastic/AppIntents/DisconnectNodeIntent.swift new file mode 100644 index 00000000..2e9986d0 --- /dev/null +++ b/Meshtastic/AppIntents/DisconnectNodeIntent.swift @@ -0,0 +1,31 @@ +// +// DisconnectNodeIntent.swift +// Meshtastic +// +// Created by Benjamin Faershtein on 4/2/25. +// + +import Foundation +import AppIntents + +struct DisconnectNodeIntent: AppIntent { + static var title: LocalizedStringResource = "Disconnect Node" + + static var description: IntentDescription = "Disconnect the currently connected node" + + + func perform() async throws -> some IntentResult { + if !BLEManager.shared.isConnected { + throw AppIntentErrors.AppIntentError.notConnected + } + + if let connectedPeripheral = BLEManager.shared.connectedPeripheral, + connectedPeripheral.peripheral.state == .connected { + BLEManager.shared.disconnectPeripheral(reconnect: false) + } else { + throw AppIntentErrors.AppIntentError.message("Error disconnecting node") + } + + return .result() + } +} diff --git a/Meshtastic/AppIntents/ShortcutsProvider.swift b/Meshtastic/AppIntents/ShortcutsProvider.swift index b21c7e7d..fcc9ffec 100644 --- a/Meshtastic/AppIntents/ShortcutsProvider.swift +++ b/Meshtastic/AppIntents/ShortcutsProvider.swift @@ -32,5 +32,13 @@ struct ShortcutsProvider: AppShortcutsProvider { "Send a \(.applicationName) group message"], shortTitle: "Group Message", systemImageName: "message") + + AppShortcut(intent: DisconnectNodeIntent(), + phrases: ["Disconnect \(.applicationName) node", + "Disconnect my \(.applicationName) node", + "Disconnect from \(.applicationName)", + "Disconnect \(.applicationName)"], + shortTitle: "Disconnect", + systemImageName: "antenna.radiowaves.left.and.right.slash") } } diff --git a/Meshtastic/Helpers/BLEManager.swift b/Meshtastic/Helpers/BLEManager.swift index d769393d..5fa57408 100644 --- a/Meshtastic/Helpers/BLEManager.swift +++ b/Meshtastic/Helpers/BLEManager.swift @@ -177,20 +177,25 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate // Disconnect Connected Peripheral func disconnectPeripheral(reconnect: Bool = true) { - - guard let connectedPeripheral = connectedPeripheral else { return } - if mqttProxyConnected { - mqttManager.mqttClientProxy?.disconnect() + // Ensure all operations run on the main thread + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + guard let connectedPeripheral = self.connectedPeripheral else { return } + + if self.mqttProxyConnected { + self.mqttManager.mqttClientProxy?.disconnect() + } + + self.automaticallyReconnect = reconnect + self.centralManager?.cancelPeripheralConnection(connectedPeripheral.peripheral) + self.FROMRADIO_characteristic = nil + self.isConnected = false + self.isSubscribed = false + self.invalidVersion = false + self.connectedVersion = "0.0.0" + self.stopScanning() + self.startScanning() } - automaticallyReconnect = reconnect - centralManager?.cancelPeripheralConnection(connectedPeripheral.peripheral) - FROMRADIO_characteristic = nil - isConnected = false - isSubscribed = false - invalidVersion = false - connectedVersion = "0.0.0" - stopScanning() - startScanning() } // Called each time a peripheral is connected diff --git a/Meshtastic/Views/Bluetooth/Connect.swift b/Meshtastic/Views/Bluetooth/Connect.swift index 5e9dd834..2ff00669 100644 --- a/Meshtastic/Views/Bluetooth/Connect.swift +++ b/Meshtastic/Views/Bluetooth/Connect.swift @@ -26,6 +26,10 @@ struct Connect: View { @State var liveActivityStarted = false @State var presentingSwitchPreferredPeripheral = false @State var selectedPeripherialId = "" + @State private var showSwipeDemo = false + @State private var swipeDemoOffset: CGFloat = 0 + @State private var showDeleteButton: Bool = false + @AppStorage("hasSeenSwipeDemo") private var hasSeenSwipeDemo = false init () { let notificationCenter = UNUserNotificationCenter.current() @@ -89,6 +93,28 @@ struct Connect: View { .font(.caption) .foregroundColor(Color.gray) .padding([.top]) + .offset(x: swipeDemoOffset) + .overlay( + GeometryReader { geometry in + ZStack { + Rectangle() + .foregroundColor(.red) + .frame(width: 80) + .offset(x: geometry.size.width - 80) + VStack { + Image(systemName: "antenna.radiowaves.left.and.right.slash") + .foregroundColor(.white) + .font(.title3) + Text("Disconnect") + .foregroundColor(.white) + .font(.caption) + } + } + .offset(x: geometry.size.width - 50) + + .opacity(showDeleteButton ? 1 : 0) + } + ) .swipeActions { Button(role: .destructive) { if let connectedPeripheral = bleManager.connectedPeripheral, @@ -123,7 +149,15 @@ struct Connect: View { Text("Short Name: \(node?.user?.shortName ?? "?")") Text("Long Name: \(node?.user?.longName?.addingVariationSelectors ?? "Unknown".localized)") Text("BLE RSSI: \(connectedPeripheral.rssi)") - + + Button(role: .destructive) { + if let connectedPeripheral = bleManager.connectedPeripheral, + connectedPeripheral.peripheral.state == .connected { + bleManager.disconnectPeripheral(reconnect: false) + } + } label: { + Label("Disconnect", systemImage: "antenna.radiowaves.left.and.right.slash") + } Button { if !bleManager.sendShutdown(fromUser: node!.user!, toUser: node!.user!, adminIndex: node!.myInfo!.adminIndex) { Logger.mesh.error("Shutdown Failed") @@ -323,6 +357,23 @@ struct Connect: View { } else { isUnsetRegion = false } + // Show swipe demo if user hasn't seen it before and we're connected + if !hasSeenSwipeDemo && bleManager.isSubscribed && node != nil { + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + withAnimation(.easeInOut(duration: 0.6)) { + swipeDemoOffset = -80 + showDeleteButton = true + } + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + withAnimation(.easeInOut(duration: 0.6)) { + swipeDemoOffset = 0 + showDeleteButton = false + } + // Mark as seen so it doesn't appear again + hasSeenSwipeDemo = true + } + } + } } catch { Logger.data.error("💥 Error fetching node info: \(error.localizedDescription, privacy: .public)") }