From 9d618fe94f951a66c67fb3f05339aa305e2b55c3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 7 Oct 2026 23:44:08 -0700 Subject: [PATCH 1/2] feat(mobile): redesign the Add environment sheet Scan-first layout: a frosted live camera card with a sharp reticle cutout, inline Address/Code fields with the pairing error shown in place, a header checkmark that spins while connecting, haptics on success/failure, and a T3 Connect section that lists account environments or offers sign-in. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../ios/T3FrostedCutoutView.swift | 49 +++ .../ios/T3NativeControlsModule.swift | 9 + .../src/components/AndroidScreenHeader.tsx | 33 +- .../src/components/FrostedCutout.ios.tsx | 14 + apps/mobile/src/components/FrostedCutout.tsx | 39 ++ .../src/components/FrostedCutout.types.ts | 8 + apps/mobile/src/components/ScreenHeader.tsx | 28 +- .../src/components/ScreenHeader.types.ts | 2 + .../connection/ConnectionSheetButton.tsx | 111 ------ .../connection/ConnectionsNewRouteScreen.tsx | 358 +++++++++++++----- .../mobile/src/native/NativeHeaderToolbar.tsx | 11 + 11 files changed, 434 insertions(+), 228 deletions(-) create mode 100644 apps/mobile/modules/t3-native-controls/ios/T3FrostedCutoutView.swift create mode 100644 apps/mobile/src/components/FrostedCutout.ios.tsx create mode 100644 apps/mobile/src/components/FrostedCutout.tsx create mode 100644 apps/mobile/src/components/FrostedCutout.types.ts delete mode 100644 apps/mobile/src/features/connection/ConnectionSheetButton.tsx diff --git a/apps/mobile/modules/t3-native-controls/ios/T3FrostedCutoutView.swift b/apps/mobile/modules/t3-native-controls/ios/T3FrostedCutoutView.swift new file mode 100644 index 000000000000..3f6dc5fda7ac --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/ios/T3FrostedCutoutView.swift @@ -0,0 +1,49 @@ +import ExpoModulesCore +import UIKit + +/// One continuous blur with a rounded-rect hole, horizontally centered. A +/// single masked effect view avoids the seams of tiling separate blurs. +final class T3FrostedCutoutView: ExpoView { + var cutoutTop: CGFloat = 0 { didSet { setNeedsLayout() } } + var cutoutWidth: CGFloat = 0 { didSet { setNeedsLayout() } } + var cutoutHeight: CGFloat = 0 { didSet { setNeedsLayout() } } + var cutoutRadius: CGFloat = 0 { didSet { setNeedsLayout() } } + /// Follows the app theme, which can differ from the system appearance. + var appearance: String = "dark" { + didSet { + effectView.effect = UIBlurEffect( + style: appearance == "light" ? .systemThinMaterialLight : .systemThinMaterialDark) + } + } + + private let effectView = UIVisualEffectView(effect: UIBlurEffect(style: .systemThinMaterialDark)) + private let cutoutMaskView = UIView() + private let maskLayer = CAShapeLayer() + + required init(appContext: AppContext? = nil) { + super.init(appContext: appContext) + isUserInteractionEnabled = false + maskLayer.fillRule = .evenOdd + cutoutMaskView.layer.addSublayer(maskLayer) + effectView.mask = cutoutMaskView + addSubview(effectView) + } + + override func layoutSubviews() { + super.layoutSubviews() + effectView.frame = bounds + cutoutMaskView.frame = bounds + maskLayer.frame = bounds + let path = UIBezierPath(rect: bounds) + if cutoutWidth > 0, cutoutHeight > 0 { + let hole = CGRect( + x: (bounds.width - cutoutWidth) / 2, + y: cutoutTop, + width: cutoutWidth, + height: cutoutHeight + ) + path.append(UIBezierPath(roundedRect: hole, cornerRadius: cutoutRadius)) + } + maskLayer.path = path.cgPath + } +} diff --git a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift index 3674e232ee5b..3749badcd5ea 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift @@ -54,6 +54,15 @@ public final class T3NativeControlsModule: Module { } } + View(T3FrostedCutoutView.self) { + ViewName("FrostedCutout") + Prop("cutoutTop") { (view: T3FrostedCutoutView, value: Double) in view.cutoutTop = CGFloat(value) } + Prop("cutoutWidth") { (view: T3FrostedCutoutView, value: Double) in view.cutoutWidth = CGFloat(value) } + Prop("cutoutHeight") { (view: T3FrostedCutoutView, value: Double) in view.cutoutHeight = CGFloat(value) } + Prop("cutoutRadius") { (view: T3FrostedCutoutView, value: Double) in view.cutoutRadius = CGFloat(value) } + Prop("appearance") { (view: T3FrostedCutoutView, value: String) in view.appearance = value } + } + View(T3ContextSheetSizeView.self) { ViewName("ContextSheetSize") Prop("contentHeight") { (view: T3ContextSheetSizeView, height: Double) in diff --git a/apps/mobile/src/components/AndroidScreenHeader.tsx b/apps/mobile/src/components/AndroidScreenHeader.tsx index a8841f390612..b660e2670195 100644 --- a/apps/mobile/src/components/AndroidScreenHeader.tsx +++ b/apps/mobile/src/components/AndroidScreenHeader.tsx @@ -1,5 +1,5 @@ import { useState, type ReactNode } from "react"; -import { View } from "react-native"; +import { ActivityIndicator, View } from "react-native"; import type { AppSymbolName } from "./AppSymbol"; import { AppText as Text } from "./AppText"; @@ -14,6 +14,7 @@ export interface AndroidHeaderAction { readonly icon: AppSymbolName; readonly onPress: () => void; readonly disabled?: boolean; + readonly loading?: boolean; readonly selected?: boolean; } @@ -84,16 +85,26 @@ export function AndroidScreenHeader(props: { ) : null} - {visibleActions.map((action) => ( - - ))} + {visibleActions.map((action) => + action.loading ? ( + + + + ) : ( + + ), + )} {overflowActions.length > 0 ? ( ({ diff --git a/apps/mobile/src/components/FrostedCutout.ios.tsx b/apps/mobile/src/components/FrostedCutout.ios.tsx new file mode 100644 index 000000000000..af2db67465cc --- /dev/null +++ b/apps/mobile/src/components/FrostedCutout.ios.tsx @@ -0,0 +1,14 @@ +import { requireNativeView } from "expo"; +import { StyleSheet, type ViewProps } from "react-native"; + +import type { FrostedCutoutProps } from "./FrostedCutout.types"; + +const NativeFrostedCutout = requireNativeView( + "T3NativeControls", + "FrostedCutout", +); + +/** Fills its parent with one native blur that leaves a rounded hole clear. */ +export function FrostedCutout(props: FrostedCutoutProps) { + return ; +} diff --git a/apps/mobile/src/components/FrostedCutout.tsx b/apps/mobile/src/components/FrostedCutout.tsx new file mode 100644 index 000000000000..b26b32703249 --- /dev/null +++ b/apps/mobile/src/components/FrostedCutout.tsx @@ -0,0 +1,39 @@ +import { useState } from "react"; +import { StyleSheet, View } from "react-native"; +import Svg, { Path } from "react-native-svg"; + +import type { FrostedCutoutProps } from "./FrostedCutout.types"; + +/** Fills its parent with a theme-matched tint that leaves a rounded hole clear. iOS blurs instead. */ +export function FrostedCutout(props: FrostedCutoutProps) { + const [width, setWidth] = useState(0); + const [height, setHeight] = useState(0); + const x = (width - props.cutoutWidth) / 2; + const y = props.cutoutTop; + const w = props.cutoutWidth; + const h = props.cutoutHeight; + const r = Math.min(props.cutoutRadius, w / 2, h / 2); + const hole = `M${x + r},${y} H${x + w - r} A${r},${r} 0 0 1 ${x + w},${y + r} V${y + h - r} A${r},${r} 0 0 1 ${x + w - r},${y + h} H${x + r} A${r},${r} 0 0 1 ${x},${y + h - r} V${y + r} A${r},${r} 0 0 1 ${x + r},${y} Z`; + + return ( + { + setWidth(event.nativeEvent.layout.width); + setHeight(event.nativeEvent.layout.height); + }} + > + {width > 0 ? ( + + + + ) : null} + + ); +} diff --git a/apps/mobile/src/components/FrostedCutout.types.ts b/apps/mobile/src/components/FrostedCutout.types.ts new file mode 100644 index 000000000000..2d5917a33b64 --- /dev/null +++ b/apps/mobile/src/components/FrostedCutout.types.ts @@ -0,0 +1,8 @@ +export interface FrostedCutoutProps { + /** Distance from the top edge to the clear hole; the hole is centered horizontally. */ + readonly cutoutTop: number; + readonly cutoutWidth: number; + readonly cutoutHeight: number; + readonly cutoutRadius: number; + readonly appearance: "light" | "dark"; +} diff --git a/apps/mobile/src/components/ScreenHeader.tsx b/apps/mobile/src/components/ScreenHeader.tsx index 4a3877571966..40a9be5a6026 100644 --- a/apps/mobile/src/components/ScreenHeader.tsx +++ b/apps/mobile/src/components/ScreenHeader.tsx @@ -1,5 +1,6 @@ import type { HeaderBarButtonMailSearchToolbarItem } from "react-native-screens"; import { useId } from "react"; +import { ActivityIndicator, View } from "react-native"; import { createNativeHeaderMenu } from "./nativeHeaderMenu.ios"; import { ScreenHeaderButton } from "./ScreenHeaderButton"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../native/StackHeader"; @@ -139,14 +140,25 @@ export function ScreenHeader(props: ScreenHeaderProps) { props.trailing) && props.options?.unstable_headerRightItems === undefined ? ( - {props.actions?.map((action) => ( - - ))} + {props.actions?.map(({ loading, ...action }) => + loading ? ( + + + + + + ) : ( + + ), + )} {refresh && !compactSearch ? ( void; readonly disabled?: boolean; + /** Shows a spinner in place of the icon while the action's work is pending. */ + readonly loading?: boolean; readonly selected?: boolean; readonly tintColor?: ColorValue; } diff --git a/apps/mobile/src/features/connection/ConnectionSheetButton.tsx b/apps/mobile/src/features/connection/ConnectionSheetButton.tsx deleted file mode 100644 index e9ac17b665ab..000000000000 --- a/apps/mobile/src/features/connection/ConnectionSheetButton.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import { SymbolView } from "../../components/AppSymbol"; -import { Platform, Pressable } from "react-native"; - -import { AppText as Text } from "../../components/AppText"; -import { cn } from "../../lib/cn"; -import { MaterialButton } from "../../components/MaterialButton"; - -const CARD_SHADOW = Platform.select({ - ios: { - shadowColor: "rgba(23,23,23,0.08)", - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 1, - shadowRadius: 16, - }, - android: { elevation: 3 }, -}); - -const CARD_SHADOW_DARK = Platform.select({ - ios: { - shadowColor: "#000", - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.18, - shadowRadius: 8, - }, - android: { elevation: 4 }, -}); - -export { CARD_SHADOW, CARD_SHADOW_DARK }; - -export function ConnectionSheetButton(props: { - readonly icon: React.ComponentProps["name"]; - readonly label: string; - readonly disabled?: boolean; - readonly tone?: "primary" | "secondary" | "danger"; - readonly compact?: boolean; - readonly fullWidth?: boolean; - readonly onPress: () => void; -}) { - if (Platform.OS === "android") - return ( - - ); - const tone = props.tone ?? "secondary"; - - const textColorClassName = - tone === "primary" - ? "accent-primary-foreground" - : tone === "danger" - ? "accent-danger-foreground" - : "accent-secondary-foreground"; - - const primaryShadow = - tone === "primary" - ? Platform.select({ - ios: { - shadowColor: "#000", - shadowOffset: { width: 0, height: 3 }, - shadowOpacity: 0.14, - shadowRadius: 6, - }, - android: { elevation: 3 }, - }) - : undefined; - - return ( - - - - {props.label} - - - ); -} diff --git a/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx index c633f67db79f..2ab933170b85 100644 --- a/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx +++ b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx @@ -1,7 +1,10 @@ import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; +import { useAuth } from "@clerk/expo"; import { CameraView, useCameraPermissions } from "expo-camera"; +import * as Haptics from "expo-haptics"; import { StackActions, + useIsFocused, useNavigation, useRoute, type StaticScreenProps, @@ -9,16 +12,24 @@ import { import type { EnvironmentId } from "@t3tools/contracts"; import { AsyncResult } from "effect/reactivity"; import { useCallback, useEffect, useRef, useState } from "react"; -import { Alert, Linking, Platform, View } from "react-native"; +import { Alert, Linking, Pressable, StyleSheet, View } from "react-native"; +import Svg, { Defs, RadialGradient, Rect, Stop } from "react-native-svg"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { cn } from "../../lib/cn"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { SettingsScreen } from "../settings/components/SettingsScreen"; -import { AppText as Text } from "../../components/AppText"; -import { ErrorBanner } from "../../components/ErrorBanner"; -import { ConnectionFormField } from "./ConnectionFormField"; -import { ConnectionSheetButton } from "./ConnectionSheetButton"; +import { AppText as Text, AppTextInput, type AppTextInputProps } from "../../components/AppText"; +import { FrostedCutout } from "../../components/FrostedCutout"; import { buildPairingUrl, extractPairingUrlFromQrPayload, parsePairingUrl } from "./pairing"; -import { useRemoteConnections } from "../../state/use-remote-environment-registry"; +import { + setPendingConnectionError, + useRemoteConnections, +} from "../../state/use-remote-environment-registry"; +import { SymbolView } from "../../components/AppSymbol"; +import { hasCloudPublicConfig } from "../cloud/publicConfig"; +import { CloudEnvironmentRows } from "./CloudEnvironmentRows"; +import { splitEnvironmentSections } from "./environmentSections"; type ConnectionsNewRouteParams = { readonly mode?: string; @@ -51,8 +62,8 @@ export function ConnectionsNewRouteScreen({ const [hostInput, setHostInput] = useState(""); const [codeInput, setCodeInput] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); - const [showScanner, setShowScanner] = useState(params.mode === "scan_qr"); const [cameraPermission, requestCameraPermission] = useCameraPermissions(); + const screenFocused = useIsFocused(); const [scannerLocked, setScannerLocked] = useState(false); const attemptedAutoConnectRef = useRef(null); @@ -84,34 +95,21 @@ export function ConnectionsNewRouteScreen({ const handleHostChange = useCallback((value: string) => { setHostInput(value); + setPendingConnectionError(null); }, []); const handleCodeChange = useCallback((value: string) => { setCodeInput(value); + setPendingConnectionError(null); }, []); - const openScanner = useCallback(async () => { - if (cameraPermission?.granted) { - setScannerLocked(false); - setShowScanner(true); - return; - } + // The error is shared app state; a stale failure must not greet the next visit. + useEffect(() => () => setPendingConnectionError(null), []); + // Opening the sheet never prompts; a tap on the scan card asks for the camera. + const handleScanPress = useCallback(async () => { const permission = await requestCameraPermission(); - if (permission.granted) { - setScannerLocked(false); - setShowScanner(true); - return; - } - - if (permission.canAskAgain) { - Alert.alert( - "Camera access needed", - "Allow camera access to scan an environment pairing QR code.", - ); - return; - } - + if (permission.granted || permission.canAskAgain) return; Alert.alert( "Camera access needed", "Camera access was denied for this app. Open Settings to enable it.", @@ -120,12 +118,7 @@ export function ConnectionsNewRouteScreen({ { text: "Open Settings", onPress: () => void Linking.openSettings() }, ], ); - }, [cameraPermission?.granted, requestCameraPermission]); - - const closeScanner = useCallback(() => { - setShowScanner(false); - setScannerLocked(false); - }, []); + }, [requestCameraPermission]); const handleQrScan = useCallback( ({ data }: { readonly data: string }) => { @@ -141,8 +134,8 @@ export function ConnectionsNewRouteScreen({ setHostInput(host); setCodeInput(code); onChangeConnectionPairingUrl(pairingUrl); - setShowScanner(false); } catch (error) { + void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); Alert.alert( "Invalid QR code", error instanceof Error ? error.message : "Scanned QR code was not recognized.", @@ -162,6 +155,11 @@ export function ConnectionsNewRouteScreen({ onChangeConnectionPairingUrl(pairingUrl); try { const result = await onConnectPress(pairingUrl, params.routeFor); + void Haptics.notificationAsync( + AsyncResult.isSuccess(result) + ? Haptics.NotificationFeedbackType.Success + : Haptics.NotificationFeedbackType.Error, + ); if (AsyncResult.isSuccess(result)) { if (replaceWithHome || !navigation.canGoBack()) { navigation.dispatch(StackActions.replace("Home")); @@ -192,96 +190,260 @@ export function ConnectionsNewRouteScreen({ return ( { - if (showScanner) { - closeScanner(); - } else { - void openScanner(); - } + void handleSubmit(); }, }, ]} > - - {showScanner ? ( - cameraPermission?.granted ? ( - - - - ) : ( - - - Camera permission is required to scan a QR code. - - { - void openScanner(); - }} - /> - - ) - ) : ( - - + { + void handleScanPress(); + }} + /> + + + Or enter it + + - - + { + if (!connectDisabled) void handleSubmit(); + }} /> - - {pairingConnectionError ? : null} - - - { - void handleSubmit(); - }} - /> - - )} + + {pairingConnectionError ?? + "For machines on your local network or tailnet. The machine keeps its own provider credentials."} + + + + {hasCloudPublicConfig() ? : null} ); } + +const SCAN_CARD_HEIGHT = 250; +const SCAN_RETICLE_SIZE = 150; +const SCAN_RETICLE_TOP = 36; +const SCAN_RETICLE_RADIUS = 28; + +/** + * Live QR scanner: the camera fills the card, blurred everywhere except the + * reticle so the code being aimed at stays sharp. Without a camera it shows the + * same frame over a dark gradient. + */ +function PairingScanCard(props: { + readonly cameraActive: boolean; + readonly onScan: (result: { readonly data: string }) => void; + readonly onScanPress: () => void; +}) { + const light = useAppearancePreferences().themeAppearance === "light"; + return ( + + + + + + + + + + + + {props.cameraActive ? ( + <> + + + + ) : null} + + + + {props.cameraActive ? null : ( + <> + + + Tap to scan + + + )} + + + Scan the code from t3 pair or desktop Connections settings + + + + ); +} + +/** + * Managed-relay alternative to manual pairing: signed in, the account's + * published environments connect with a switch; signed out, one row opens the + * T3 Account sheet. + */ +function T3ConnectSection() { + const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); + const navigation = useNavigation(); + const { connectedEnvironments, onSetEnvironmentEnabled, onRemoveEnvironmentPress } = + useRemoteConnections(); + const { connectedCloudEnvironments } = splitEnvironmentSections({ + connectedEnvironments, + cloudEnvironments: null, + }); + + return ( + + + Or use the managed relay + + {isSignedIn ? ( + + ) : ( + navigation.navigate("SettingsSheet", { screen: "SettingsAuth" })} + className="min-h-13 flex-row items-center gap-3 rounded-[26px] border-continuous bg-grouped-card px-4 active:opacity-70" + > + + Sign in to T3 Connect + + Reach your machines from anywhere, no network setup. + + + + + )} + + ); +} + +/** Inline grouped-list field: fixed label column, input filling the rest. */ +function PairingInputRow({ + label, + ...inputProps +}: Omit & { + readonly label: string; +}) { + return ( + + + {label} + + + + ); +} diff --git a/apps/mobile/src/native/NativeHeaderToolbar.tsx b/apps/mobile/src/native/NativeHeaderToolbar.tsx index 3bab99546bb5..5bf5aa27c91d 100644 --- a/apps/mobile/src/native/NativeHeaderToolbar.tsx +++ b/apps/mobile/src/native/NativeHeaderToolbar.tsx @@ -118,6 +118,10 @@ function convertToolbarChild(child: ReactNode): NativeStackHeaderItem | null { } const typeName = elementTypeName(child); + if (typeName === "NativeHeaderToolbarCustom" && isValidElement(child.props.children)) { + return { type: "custom", element: child.props.children }; + } + if (typeName === "NativeHeaderToolbarButton") { return { type: "button", @@ -263,6 +267,12 @@ function NativeHeaderToolbarMenuAction(_props: { } NativeHeaderToolbarMenuAction.displayName = "NativeHeaderToolbarMenuAction"; +/** Renders an arbitrary element as a bar item, e.g. a spinner standing in for a button. */ +function NativeHeaderToolbarCustom(_props: { readonly children: ReactElement }) { + return null; +} +NativeHeaderToolbarCustom.displayName = "NativeHeaderToolbarCustom"; + function NativeHeaderToolbarLabel(_props: { readonly children?: ReactNode }) { return null; } @@ -284,6 +294,7 @@ NativeHeaderToolbarSearchBarSlot.displayName = "NativeHeaderToolbarSearchBarSlot export const NativeHeaderToolbar = Object.assign(NativeHeaderToolbarRoot, { Button: NativeHeaderToolbarButton, + Custom: NativeHeaderToolbarCustom, Label: NativeHeaderToolbarLabel, Menu: Object.assign(NativeHeaderToolbarMenu, { Action: NativeHeaderToolbarMenuAction, From 5fff28a6e6103033dbeae18893b8442b74c17c2b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 7 Oct 2026 23:51:01 -0700 Subject: [PATCH 2/2] fix(mobile): a good QR scan no longer re-reads over edited fields Co-Authored-By: Claude Opus 5.5 (1M context) --- .../connection/ConnectionsNewRouteScreen.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx index 2ab933170b85..81d48a94848c 100644 --- a/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx +++ b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx @@ -65,6 +65,8 @@ export function ConnectionsNewRouteScreen({ const [cameraPermission, requestCameraPermission] = useCameraPermissions(); const screenFocused = useIsFocused(); const [scannerLocked, setScannerLocked] = useState(false); + // A good scan parks the camera so it can't re-read the code over later edits. + const [scanComplete, setScanComplete] = useState(false); const attemptedAutoConnectRef = useRef(null); const headerIconColor = useUniwindTheme()["--color-icon"]; @@ -109,7 +111,11 @@ export function ConnectionsNewRouteScreen({ // Opening the sheet never prompts; a tap on the scan card asks for the camera. const handleScanPress = useCallback(async () => { const permission = await requestCameraPermission(); - if (permission.granted || permission.canAskAgain) return; + if (permission.granted) { + setScanComplete(false); + return; + } + if (permission.canAskAgain) return; Alert.alert( "Camera access needed", "Camera access was denied for this app. Open Settings to enable it.", @@ -134,13 +140,14 @@ export function ConnectionsNewRouteScreen({ setHostInput(host); setCodeInput(code); onChangeConnectionPairingUrl(pairingUrl); + setScanComplete(true); + setScannerLocked(false); } catch (error) { void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); Alert.alert( "Invalid QR code", error instanceof Error ? error.message : "Scanned QR code was not recognized.", ); - } finally { setTimeout(() => { setScannerLocked(false); }, 600); @@ -217,7 +224,7 @@ export function ConnectionsNewRouteScreen({ > { void handleScanPress();