diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 6850954b..658dadb3 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -68,6 +68,8 @@ DD6193792863875F00E59241 /* SerialConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD6193782863875F00E59241 /* SerialConfig.swift */; }; DD73FD1128750779000852D6 /* PositionLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD73FD1028750779000852D6 /* PositionLog.swift */; }; DD769E0328D18BF1001A3F05 /* DeviceMetricsLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD769E0228D18BF0001A3F05 /* DeviceMetricsLog.swift */; }; + DD77093B2AA1ABB8007A8BF0 /* BluetoothTips.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD77093A2AA1ABB8007A8BF0 /* BluetoothTips.swift */; }; + DD77093D2AA1AFA3007A8BF0 /* ChannelTips.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD77093C2AA1AFA3007A8BF0 /* ChannelTips.swift */; }; DD798B072915928D005217CD /* ChannelMessageList.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD798B062915928D005217CD /* ChannelMessageList.swift */; }; DD8169F9271F1A6100F4AB02 /* MeshLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD8169F8271F1A6100F4AB02 /* MeshLogger.swift */; }; DD8169FB271F1F3A00F4AB02 /* MeshLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD8169FA271F1F3A00F4AB02 /* MeshLog.swift */; }; @@ -268,6 +270,8 @@ DD6193782863875F00E59241 /* SerialConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SerialConfig.swift; sourceTree = ""; }; DD73FD1028750779000852D6 /* PositionLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PositionLog.swift; sourceTree = ""; }; DD769E0228D18BF0001A3F05 /* DeviceMetricsLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceMetricsLog.swift; sourceTree = ""; }; + DD77093A2AA1ABB8007A8BF0 /* BluetoothTips.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothTips.swift; sourceTree = ""; }; + DD77093C2AA1AFA3007A8BF0 /* ChannelTips.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelTips.swift; sourceTree = ""; }; DD798B062915928D005217CD /* ChannelMessageList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelMessageList.swift; sourceTree = ""; }; DD8169F8271F1A6100F4AB02 /* MeshLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshLogger.swift; sourceTree = ""; }; DD8169FA271F1F3A00F4AB02 /* MeshLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshLog.swift; sourceTree = ""; }; @@ -543,6 +547,15 @@ path = Module; sourceTree = ""; }; + DD7709392AA1ABA1007A8BF0 /* Tips */ = { + isa = PBXGroup; + children = ( + DD77093A2AA1ABB8007A8BF0 /* BluetoothTips.swift */, + DD77093C2AA1AFA3007A8BF0 /* ChannelTips.swift */, + ); + path = Tips; + sourceTree = ""; + }; DD86D40D2881BDB300BAEB7A /* Export */ = { isa = PBXGroup; children = ( @@ -630,6 +643,7 @@ DDC2E15626CE248E0042C5E4 /* Meshtastic */ = { isa = PBXGroup; children = ( + DD7709392AA1ABA1007A8BF0 /* Tips */, DDDB443E29F79A9400EE2349 /* Extensions */, DD90860A26F645B700DC5189 /* Meshtastic.entitlements */, DD8ED9C6289CE4A100B3B0AB /* Enums */, @@ -1036,6 +1050,7 @@ DDA6B2EB28420A7B003E8C16 /* NodeAnnotation.swift in Sources */, DDC2E1A726CEB3400042C5E4 /* LocationHelper.swift in Sources */, DD5394FE276BA0EF00AD86B1 /* PositionEntityExtension.swift in Sources */, + DD77093D2AA1AFA3007A8BF0 /* ChannelTips.swift in Sources */, DD913639270DFF4C00D7ACF3 /* LocalNotificationManager.swift in Sources */, DDDB444C29F8AAA600EE2349 /* Color.swift in Sources */, DDB8F4122A9EE5DD00230ECE /* UserList.swift in Sources */, @@ -1048,6 +1063,7 @@ DDDB445229F8ACF900EE2349 /* Date.swift in Sources */, DDC4D568275499A500A4208E /* Persistence.swift in Sources */, DDD6EEAF29BC024700383354 /* Firmware.swift in Sources */, + DD77093B2AA1ABB8007A8BF0 /* BluetoothTips.swift in Sources */, DD90860E26F69BAE00DC5189 /* NodeMap.swift in Sources */, DDC94FCE29CF55310082EA6E /* RtttlConfig.swift in Sources */, DD964FBD296E6B01007C176F /* EmojiOnlyTextField.swift in Sources */, diff --git a/Meshtastic/MeshtasticApp.swift b/Meshtastic/MeshtasticApp.swift index b5f24076..bcf3c8a5 100644 --- a/Meshtastic/MeshtasticApp.swift +++ b/Meshtastic/MeshtasticApp.swift @@ -2,6 +2,9 @@ import SwiftUI import CoreData +#if canImport(TipKit) +import TipKit +#endif @main struct MeshtasticAppleApp: App { @@ -92,6 +95,25 @@ struct MeshtasticAppleApp: App { } } }) + .task { + if #available(iOS 17.0, macOS 14.0, *) { + #if DEBUG + /// Optionally, call `Tips.resetDatastore()` before `Tips.configure()` to reset the state of all tips. This will allow tips to re-appear even after they have been dismissed by the user. + /// This is for testing only, and should not be enabled in release builds. + try? Tips.resetDatastore() + #endif + + try? Tips.configure( + [ + // Reset which tips have been shown and what parameters have been tracked, useful during testing and for this sample project + .datastoreLocation(.applicationDefault), + // When should the tips be presented? If you use .immediate, they'll all be presented whenever a screen with a tip appears. + // You can adjust this on per tip level as well + .displayFrequency(.immediate) + ] + ) + } + } } .onChange(of: scenePhase) { (newScenePhase) in switch newScenePhase { diff --git a/Meshtastic/Tips/BluetoothTips.swift b/Meshtastic/Tips/BluetoothTips.swift new file mode 100644 index 00000000..6d540744 --- /dev/null +++ b/Meshtastic/Tips/BluetoothTips.swift @@ -0,0 +1,29 @@ +// +// BluetoothTips.swift +// Meshtastic +// +// Copyright(c) Garth Vander Houwen 8/31/23. +// +import SwiftUI +#if canImport(TipKit) +import TipKit +#endif + +@available(iOS 17.0, macOS 14.0, *) +struct BluetoothConnectionTip: Tip { + + var id: String { + return "tip-bluetooth-connect" + } + var title: Text { + Text("Connected LoRa Radio Info") + } + + var message: Text? { + Text("Shows information for the Lora radio currently connected via bluetooth. You can swipe left to disconnect the radio and long press to view stats or start the live activity.") + } + + var image: Image? { + Image(systemName: "questionmark.circle") + } +} diff --git a/Meshtastic/Tips/ChannelTips.swift b/Meshtastic/Tips/ChannelTips.swift new file mode 100644 index 00000000..fb557dcd --- /dev/null +++ b/Meshtastic/Tips/ChannelTips.swift @@ -0,0 +1,29 @@ +// + // ChannelTips.swift + // Meshtastic + // + // Copyright(c) Garth Vander Houwen 8/31/23. + // + import SwiftUI + #if canImport(TipKit) + import TipKit + #endif + + @available(iOS 17.0, macOS 14.0, *) + struct ShareChannelsTip: Tip { + + var id: String { + return "tip-channels-share" + } + var title: Text { + Text("Sharing Meshtastic Channels") + } + + var message: Text? { + Text("In a Meshtastic LoRa Mesh there are up to 8 channels. The first one is the Primary channel where most activity happens and is required. If you don't share your primary channel your first shared channel becomes the primary channel on the other network. It talks on its primary and your secondary channel. A channel with the name 'admin' controls nodes remotely. Other channels are for private groups, each with its own key.") + } + + var image: Image? { + Image(systemName: "questionmark.circle") + } + } diff --git a/Meshtastic/Views/Bluetooth/Connect.swift b/Meshtastic/Views/Bluetooth/Connect.swift index c5633eb7..efde3cf5 100644 --- a/Meshtastic/Views/Bluetooth/Connect.swift +++ b/Meshtastic/Views/Bluetooth/Connect.swift @@ -10,8 +10,8 @@ import MapKit import CoreData import CoreLocation import CoreBluetooth -#if canImport(ActivityKit) -import ActivityKit +#if canImport(TipKit) +import TipKit #endif struct Connect: View { @@ -47,6 +47,9 @@ struct Connect: View { if bleManager.isSwitchedOn { Section(header: Text("connected.radio").font(.title)) { if bleManager.connectedPeripheral != nil && bleManager.connectedPeripheral.peripheral.state == .connected { + if #available(iOS 17.0, macOS 14.0, *) { + TipView(BluetoothConnectionTip(), arrowEdge: .bottom) + } HStack { VStack(alignment: .center) { CircleText(text: node?.user?.shortName ?? "???", color: Color(UIColor(hex: UInt32(node?.num ?? 0))), circleSize: 90, fontSize: (node?.user?.shortName ?? "???").isEmoji() ? 52 : (node?.user?.shortName?.count ?? 0 == 4 ? 26 : 36), textColor: UIColor(hex: UInt32(node?.num ?? 0)).isLight() ? .black : .white ) diff --git a/Meshtastic/Views/Settings/ShareChannels.swift b/Meshtastic/Views/Settings/ShareChannels.swift index 86d79877..f8bc464a 100644 --- a/Meshtastic/Views/Settings/ShareChannels.swift +++ b/Meshtastic/Views/Settings/ShareChannels.swift @@ -7,6 +7,9 @@ import SwiftUI import CoreData import CoreImage.CIFilterBuiltins +#if canImport(TipKit) +import TipKit +#endif struct QrCodeImage { let context = CIContext() @@ -43,7 +46,6 @@ struct ShareChannels: View { @State var includeChannel5 = true @State var includeChannel6 = true @State var includeChannel7 = true - @State var isPresentingHelp = false var node: NodeInfoEntity? @State private var channelsUrl = "https://www.meshtastic.org/e/#" var qrCodeImage = QrCodeImage() @@ -52,206 +54,168 @@ struct ShareChannels: View { GeometryReader { bounds in let smallest = min(bounds.size.width, bounds.size.height) ScrollView { - if node != nil && node?.myInfo != nil { - Grid { + if node != nil && node?.myInfo != nil { + + if #available(iOS 17.0, macOS 14.0, *) { + VStack { + TipView(ShareChannelsTip(), arrowEdge: .top) + } + } + Grid { + GridRow { + Spacer() + Text("include") + .font(.caption) + .fontWeight(.bold) + .padding(.trailing) + Text("channel") + .font(.caption) + .fontWeight(.bold) + .padding(.trailing) + Text("encrypted") + .font(.caption) + .fontWeight(.bold) + } + ForEach(node?.myInfo?.channels?.array as? [ChannelEntity] ?? [], id: \.self) { (channel: ChannelEntity) in GridRow { Spacer() - Text("include") - .font(.caption) - .fontWeight(.bold) - .padding(.trailing) - Text("channel") - .font(.caption) - .fontWeight(.bold) - .padding(.trailing) - Text("encrypted") - .font(.caption) - .fontWeight(.bold) - } - ForEach(node?.myInfo?.channels?.array as? [ChannelEntity] ?? [], id: \.self) { (channel: ChannelEntity) in - GridRow { - Spacer() - if channel.index == 0 { - Toggle("Channel 0 Included", isOn: $includeChannel0) - .toggleStyle(.switch) - .labelsHidden() - Text(((channel.name!.isEmpty ? "Primary" : channel.name) ?? "Primary").camelCaseToWords()) - if channel.psk?.hexDescription.count ?? 0 < 3 { - Image(systemName: "lock.slash") - .foregroundColor(.red) - } else { - Image(systemName: "lock.fill") - .foregroundColor(.green) - } - } else if channel.index == 1 && channel.role > 0 { - Toggle("Channel 1 Included", isOn: $includeChannel1) - .toggleStyle(.switch) - .labelsHidden() - .disabled(channel.role == 1) - Text(((channel.name!.isEmpty ? "Channel\(channel.index)" : channel.name) ?? "Channel\(channel.index)").camelCaseToWords()).fixedSize() - if channel.psk?.hexDescription.count ?? 0 < 3 { - Image(systemName: "lock.slash") - .foregroundColor(.red) - } else { - Image(systemName: "lock.fill") - .foregroundColor(.green) - } - } else if channel.index == 2 && channel.role > 0 { - Toggle("Channel 2 Included", isOn: $includeChannel2) - .toggleStyle(.switch) - .labelsHidden() - .disabled(channel.role == 1) - Text(((channel.name!.isEmpty ? "Channel\(channel.index)" : channel.name) ?? "Channel\(channel.index)").camelCaseToWords()).fixedSize() - if channel.psk?.hexDescription.count ?? 0 < 3 { - Image(systemName: "lock.slash") - .foregroundColor(.red) - } else { - Image(systemName: "lock.fill") - .foregroundColor(.green) - } - } else if channel.index == 3 && channel.role > 0 { - Toggle("Channel 3 Included", isOn: $includeChannel3) - .toggleStyle(.switch) - .labelsHidden() - .disabled(channel.role == 1) - Text(((channel.name!.isEmpty ? "Channel\(channel.index)" : channel.name) ?? "Channel\(channel.index)").camelCaseToWords()).fixedSize() - if channel.psk?.hexDescription.count ?? 0 < 3 { - Image(systemName: "lock.slash") - .foregroundColor(.red) - } else { - Image(systemName: "lock.fill") - .foregroundColor(.green) - } - } else if channel.index == 4 && channel.role > 0 { - Toggle("Channel 4 Included", isOn: $includeChannel4) - .toggleStyle(.switch) - .labelsHidden() - .disabled(channel.role == 1) - Text(((channel.name!.isEmpty ? "Channel\(channel.index)" : channel.name) ?? "Channel\(channel.index)").camelCaseToWords()).fixedSize() - if channel.psk?.hexDescription.count ?? 0 < 3 { - Image(systemName: "lock.slash") - .foregroundColor(.red) - } else { - Image(systemName: "lock.fill") - .foregroundColor(.green) - } - } else if channel.index == 5 && channel.role > 0 { - Toggle("Channel 5 Included", isOn: $includeChannel5) - .toggleStyle(.switch) - .labelsHidden() - .disabled(channel.role == 1) - Text(((channel.name!.isEmpty ? "Channel\(channel.index)" : channel.name) ?? "Channel\(channel.index)").camelCaseToWords()).fixedSize() - if channel.psk?.hexDescription.count ?? 0 < 3 { - Image(systemName: "lock.slash") - .foregroundColor(.red) - } else { - Image(systemName: "lock.fill") - .foregroundColor(.green) - } - } else if channel.index == 6 && channel.role > 0 { - Toggle("Channel 6 Included", isOn: $includeChannel6) - .toggleStyle(.switch) - .labelsHidden() - .disabled(channel.role == 1) - Text(((channel.name!.isEmpty ? "Channel\(channel.index)" : channel.name) ?? "Channel\(channel.index)").camelCaseToWords()).fixedSize() - if channel.psk?.hexDescription.count ?? 0 < 3 { - Image(systemName: "lock.slash") - .foregroundColor(.red) - } else { - Image(systemName: "lock.fill") - .foregroundColor(.green) - } - } else if channel.index == 7 && channel.role > 0 { - Toggle("Channel 7 Included", isOn: $includeChannel7) - .toggleStyle(.switch) - .labelsHidden() - .disabled(channel.role == 1) - Text(((channel.name!.isEmpty ? "Channel\(channel.index)" : channel.name) ?? "Channel\(channel.index)").camelCaseToWords()).fixedSize() - if channel.psk?.hexDescription.count ?? 0 < 3 { - Image(systemName: "lock.slash") - .foregroundColor(.red) - } else { - Image(systemName: "lock.fill") - .foregroundColor(.green) - } + if channel.index == 0 { + Toggle("Channel 0 Included", isOn: $includeChannel0) + .toggleStyle(.switch) + .labelsHidden() + Text(((channel.name!.isEmpty ? "Primary" : channel.name) ?? "Primary").camelCaseToWords()) + if channel.psk?.hexDescription.count ?? 0 < 3 { + Image(systemName: "lock.slash") + .foregroundColor(.red) + } else { + Image(systemName: "lock.fill") + .foregroundColor(.green) + } + } else if channel.index == 1 && channel.role > 0 { + Toggle("Channel 1 Included", isOn: $includeChannel1) + .toggleStyle(.switch) + .labelsHidden() + .disabled(channel.role == 1) + Text(((channel.name!.isEmpty ? "Channel\(channel.index)" : channel.name) ?? "Channel\(channel.index)").camelCaseToWords()).fixedSize() + if channel.psk?.hexDescription.count ?? 0 < 3 { + Image(systemName: "lock.slash") + .foregroundColor(.red) + } else { + Image(systemName: "lock.fill") + .foregroundColor(.green) + } + } else if channel.index == 2 && channel.role > 0 { + Toggle("Channel 2 Included", isOn: $includeChannel2) + .toggleStyle(.switch) + .labelsHidden() + .disabled(channel.role == 1) + Text(((channel.name!.isEmpty ? "Channel\(channel.index)" : channel.name) ?? "Channel\(channel.index)").camelCaseToWords()).fixedSize() + if channel.psk?.hexDescription.count ?? 0 < 3 { + Image(systemName: "lock.slash") + .foregroundColor(.red) + } else { + Image(systemName: "lock.fill") + .foregroundColor(.green) + } + } else if channel.index == 3 && channel.role > 0 { + Toggle("Channel 3 Included", isOn: $includeChannel3) + .toggleStyle(.switch) + .labelsHidden() + .disabled(channel.role == 1) + Text(((channel.name!.isEmpty ? "Channel\(channel.index)" : channel.name) ?? "Channel\(channel.index)").camelCaseToWords()).fixedSize() + if channel.psk?.hexDescription.count ?? 0 < 3 { + Image(systemName: "lock.slash") + .foregroundColor(.red) + } else { + Image(systemName: "lock.fill") + .foregroundColor(.green) + } + } else if channel.index == 4 && channel.role > 0 { + Toggle("Channel 4 Included", isOn: $includeChannel4) + .toggleStyle(.switch) + .labelsHidden() + .disabled(channel.role == 1) + Text(((channel.name!.isEmpty ? "Channel\(channel.index)" : channel.name) ?? "Channel\(channel.index)").camelCaseToWords()).fixedSize() + if channel.psk?.hexDescription.count ?? 0 < 3 { + Image(systemName: "lock.slash") + .foregroundColor(.red) + } else { + Image(systemName: "lock.fill") + .foregroundColor(.green) + } + } else if channel.index == 5 && channel.role > 0 { + Toggle("Channel 5 Included", isOn: $includeChannel5) + .toggleStyle(.switch) + .labelsHidden() + .disabled(channel.role == 1) + Text(((channel.name!.isEmpty ? "Channel\(channel.index)" : channel.name) ?? "Channel\(channel.index)").camelCaseToWords()).fixedSize() + if channel.psk?.hexDescription.count ?? 0 < 3 { + Image(systemName: "lock.slash") + .foregroundColor(.red) + } else { + Image(systemName: "lock.fill") + .foregroundColor(.green) + } + } else if channel.index == 6 && channel.role > 0 { + Toggle("Channel 6 Included", isOn: $includeChannel6) + .toggleStyle(.switch) + .labelsHidden() + .disabled(channel.role == 1) + Text(((channel.name!.isEmpty ? "Channel\(channel.index)" : channel.name) ?? "Channel\(channel.index)").camelCaseToWords()).fixedSize() + if channel.psk?.hexDescription.count ?? 0 < 3 { + Image(systemName: "lock.slash") + .foregroundColor(.red) + } else { + Image(systemName: "lock.fill") + .foregroundColor(.green) + } + } else if channel.index == 7 && channel.role > 0 { + Toggle("Channel 7 Included", isOn: $includeChannel7) + .toggleStyle(.switch) + .labelsHidden() + .disabled(channel.role == 1) + Text(((channel.name!.isEmpty ? "Channel\(channel.index)" : channel.name) ?? "Channel\(channel.index)").camelCaseToWords()).fixedSize() + if channel.psk?.hexDescription.count ?? 0 < 3 { + Image(systemName: "lock.slash") + .foregroundColor(.red) + } else { + Image(systemName: "lock.fill") + .foregroundColor(.green) } - Spacer() } + Spacer() } } + } - let qrImage = qrCodeImage.generateQRCode(from: channelsUrl) - VStack { - if node != nil { - ShareLink("Share QR Code & Link", - item: Image(uiImage: qrImage), - subject: Text("Meshtastic Node \(node?.user?.shortName ?? "????") has shared channels with you"), - message: Text(channelsUrl), - preview: SharePreview("Meshtastic Node \(node?.user?.shortName ?? "????") has shared channels with you", - image: Image(uiImage: qrImage)) + let qrImage = qrCodeImage.generateQRCode(from: channelsUrl) + VStack { + if node != nil { + ShareLink("Share QR Code & Link", + item: Image(uiImage: qrImage), + subject: Text("Meshtastic Node \(node?.user?.shortName ?? "????") has shared channels with you"), + message: Text(channelsUrl), + preview: SharePreview("Meshtastic Node \(node?.user?.shortName ?? "????") has shared channels with you", + image: Image(uiImage: qrImage)) + ) + .buttonStyle(.bordered) + .buttonBorderShape(.capsule) + .controlSize(.large) + .padding(.bottom) + + Image(uiImage: qrImage) + .resizable() + .scaledToFit() + .frame( + minWidth: smallest * 0.95, + maxWidth: smallest * 0.95, + minHeight: smallest * 0.95, + maxHeight: smallest * 0.95, + alignment: .top ) - .buttonStyle(.bordered) - .buttonBorderShape(.capsule) - .controlSize(.large) - .padding(.bottom) - - Image(uiImage: qrImage) - .resizable() - .scaledToFit() - .frame( - minWidth: smallest * 0.95, - maxWidth: smallest * 0.95, - minHeight: smallest * 0.95, - maxHeight: smallest * 0.95, - alignment: .top - ) - Button { - isPresentingHelp = true - } label: { - Label("Help Me!", systemImage: "lifepreserver") - } - .buttonStyle(.bordered) - .buttonBorderShape(.capsule) - .controlSize(.small) - } } } - } - .sheet(isPresented: $isPresentingHelp) { - VStack { - Text("Meshtastic Channels").font(.title) - Text("A Meshtastic LoRa Mesh network can have up to 8 distinct channels.") - .font(.headline) - .padding(.bottom) - Text("Primary Channel").font(.title2) - Text("The first channel is the Primary channel and is where much of the mesh activity takes place. DM's are only available on the primary channel and it can not be disabled. If you don't share your primary channel, the first channel will become the primary channel on the other network and will allow communication with your mesh on the group channel.") - .font(.callout) - .padding([.leading, .trailing, .bottom]) - Text("Admin Channel").font(.title2) - Text("A channel with the name 'admin' is the Admin channel and can be used to remotely administer nodes on your mesh, text messages can not be sent over the admin channel.") - .font(.callout) - .padding([.leading, .trailing, .bottom]) - Text("Private Channels").font(.title2) - Text("The other channels can be used for private group converations. Each of these groups has its own encryption key.") - .font(.callout) - .padding([.leading, .trailing, .bottom]) - Divider() } - .padding() - .presentationDetents([.large]) - .presentationDragIndicator(.automatic) - - #if targetEnvironment(macCatalyst) - Button { - isPresentingHelp = false - } label: { - Label("close", systemImage: "xmark") - } - .buttonStyle(.bordered) - .buttonBorderShape(.capsule) - .controlSize(.large) - .padding() - #endif } .navigationTitle("generate.qr.code") .navigationBarTitleDisplayMode(.inline)