authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-07 23:54:05-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-07 23:54:05-07:00
log30cc788975500a8c00d32a50f348174d1ce578d1
tree41ea2e7856465cc8378296bb47a36f7fc1a3a749
parent73e097b8c86000ddc4b38926ba86e1595109d78e
signature Signed by PGP key B5690EEEBB952194

feat(mobile): redesign the Add environment sheet (#17092)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

11 files changed, 439 insertions(+), 226 deletions(-)

apps/mobile/modules/t3-native-controls/ios/T3FrostedCutoutView.swift created+49
......@@ -0,0 +1,49 @@
1import ExpoModulesCore
2import UIKit
3
4/// One continuous blur with a rounded-rect hole, horizontally centered. A
5/// single masked effect view avoids the seams of tiling separate blurs.
6final class T3FrostedCutoutView: ExpoView {
7 var cutoutTop: CGFloat = 0 { didSet { setNeedsLayout() } }
8 var cutoutWidth: CGFloat = 0 { didSet { setNeedsLayout() } }
9 var cutoutHeight: CGFloat = 0 { didSet { setNeedsLayout() } }
10 var cutoutRadius: CGFloat = 0 { didSet { setNeedsLayout() } }
11 /// Follows the app theme, which can differ from the system appearance.
12 var appearance: String = "dark" {
13 didSet {
14 effectView.effect = UIBlurEffect(
15 style: appearance == "light" ? .systemThinMaterialLight : .systemThinMaterialDark)
16 }
17 }
18
19 private let effectView = UIVisualEffectView(effect: UIBlurEffect(style: .systemThinMaterialDark))
20 private let cutoutMaskView = UIView()
21 private let maskLayer = CAShapeLayer()
22
23 required init(appContext: AppContext? = nil) {
24 super.init(appContext: appContext)
25 isUserInteractionEnabled = false
26 maskLayer.fillRule = .evenOdd
27 cutoutMaskView.layer.addSublayer(maskLayer)
28 effectView.mask = cutoutMaskView
29 addSubview(effectView)
30 }
31
32 override func layoutSubviews() {
33 super.layoutSubviews()
34 effectView.frame = bounds
35 cutoutMaskView.frame = bounds
36 maskLayer.frame = bounds
37 let path = UIBezierPath(rect: bounds)
38 if cutoutWidth > 0, cutoutHeight > 0 {
39 let hole = CGRect(
40 x: (bounds.width - cutoutWidth) / 2,
41 y: cutoutTop,
42 width: cutoutWidth,
43 height: cutoutHeight
44 )
45 path.append(UIBezierPath(roundedRect: hole, cornerRadius: cutoutRadius))
46 }
47 maskLayer.path = path.cgPath
48 }
49}
apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift+9
......@@ -54,6 +54,15 @@ public final class T3NativeControlsModule: Module {
5454 }
5555 }
5656
57 View(T3FrostedCutoutView.self) {
58 ViewName("FrostedCutout")
59 Prop("cutoutTop") { (view: T3FrostedCutoutView, value: Double) in view.cutoutTop = CGFloat(value) }
60 Prop("cutoutWidth") { (view: T3FrostedCutoutView, value: Double) in view.cutoutWidth = CGFloat(value) }
61 Prop("cutoutHeight") { (view: T3FrostedCutoutView, value: Double) in view.cutoutHeight = CGFloat(value) }
62 Prop("cutoutRadius") { (view: T3FrostedCutoutView, value: Double) in view.cutoutRadius = CGFloat(value) }
63 Prop("appearance") { (view: T3FrostedCutoutView, value: String) in view.appearance = value }
64 }
65
5766 View(T3ContextSheetSizeView.self) {
5867 ViewName("ContextSheetSize")
5968 Prop("contentHeight") { (view: T3ContextSheetSizeView, height: Double) in
apps/mobile/src/components/AndroidScreenHeader.tsx+22-11
......@@ -1,5 +1,5 @@
11import { useState, type ReactNode } from "react";
2import { View } from "react-native";
2import { ActivityIndicator, View } from "react-native";
33
44import type { AppSymbolName } from "./AppSymbol";
55import { AppText as Text } from "./AppText";
......@@ -14,6 +14,7 @@ export interface AndroidHeaderAction {
1414 readonly icon: AppSymbolName;
1515 readonly onPress: () => void;
1616 readonly disabled?: boolean;
17 readonly loading?: boolean;
1718 readonly selected?: boolean;
1819}
1920
......@@ -84,16 +85,26 @@ export function AndroidScreenHeader(props: {
8485 ) : null}
8586 </View>
8687
87 {visibleActions.map((action) => (
88 <AndroidHeaderIconButton
89 key={action.accessibilityLabel}
90 accessibilityLabel={action.accessibilityLabel}
91 disabled={action.disabled}
92 selected={action.selected}
93 icon={action.icon}
94 onPress={action.onPress}
95 />
96 ))}
88 {visibleActions.map((action) =>
89 action.loading ? (
90 <View
91 key={action.accessibilityLabel}
92 accessibilityLabel={action.accessibilityLabel}
93 className="size-12 items-center justify-center"
94 >
95 <ActivityIndicator colorClassName="accent-header-foreground" />
96 </View>
97 ) : (
98 <AndroidHeaderIconButton
99 key={action.accessibilityLabel}
100 accessibilityLabel={action.accessibilityLabel}
101 disabled={action.disabled}
102 selected={action.selected}
103 icon={action.icon}
104 onPress={action.onPress}
105 />
106 ),
107 )}
97108 {overflowActions.length > 0 ? (
98109 <AndroidAnchoredMenu
99110 actions={overflowActions.map((action, index) => ({
apps/mobile/src/components/FrostedCutout.ios.tsx created+14
......@@ -0,0 +1,14 @@
1import { requireNativeView } from "expo";
2import { StyleSheet, type ViewProps } from "react-native";
3
4import type { FrostedCutoutProps } from "./FrostedCutout.types";
5
6const NativeFrostedCutout = requireNativeView<ViewProps & FrostedCutoutProps>(
7 "T3NativeControls",
8 "FrostedCutout",
9);
10
11/** Fills its parent with one native blur that leaves a rounded hole clear. */
12export function FrostedCutout(props: FrostedCutoutProps) {
13 return <NativeFrostedCutout {...props} pointerEvents="none" style={StyleSheet.absoluteFill} />;
14}
apps/mobile/src/components/FrostedCutout.tsx created+39
......@@ -0,0 +1,39 @@
1import { useState } from "react";
2import { StyleSheet, View } from "react-native";
3import Svg, { Path } from "react-native-svg";
4
5import type { FrostedCutoutProps } from "./FrostedCutout.types";
6
7/** Fills its parent with a theme-matched tint that leaves a rounded hole clear. iOS blurs instead. */
8export function FrostedCutout(props: FrostedCutoutProps) {
9 const [width, setWidth] = useState(0);
10 const [height, setHeight] = useState(0);
11 const x = (width - props.cutoutWidth) / 2;
12 const y = props.cutoutTop;
13 const w = props.cutoutWidth;
14 const h = props.cutoutHeight;
15 const r = Math.min(props.cutoutRadius, w / 2, h / 2);
16 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`;
17
18 return (
19 <View
20 pointerEvents="none"
21 style={StyleSheet.absoluteFill}
22 onLayout={(event) => {
23 setWidth(event.nativeEvent.layout.width);
24 setHeight(event.nativeEvent.layout.height);
25 }}
26 >
27 {width > 0 ? (
28 <Svg height={height} width={width}>
29 <Path
30 d={`M0,0 H${width} V${height} H0 Z ${hole}`}
31 fill={props.appearance === "light" ? "#ffffff" : "#000000"}
32 fillOpacity={0.55}
33 fillRule="evenodd"
34 />
35 </Svg>
36 ) : null}
37 </View>
38 );
39}
apps/mobile/src/components/FrostedCutout.types.ts created+8
......@@ -0,0 +1,8 @@
1export interface FrostedCutoutProps {
2 /** Distance from the top edge to the clear hole; the hole is centered horizontally. */
3 readonly cutoutTop: number;
4 readonly cutoutWidth: number;
5 readonly cutoutHeight: number;
6 readonly cutoutRadius: number;
7 readonly appearance: "light" | "dark";
8}
apps/mobile/src/components/ScreenHeader.tsx+20-8
......@@ -1,5 +1,6 @@
11import type { HeaderBarButtonMailSearchToolbarItem } from "react-native-screens";
22import { useId } from "react";
3import { ActivityIndicator, View } from "react-native";
34import { createNativeHeaderMenu } from "./nativeHeaderMenu.ios";
45import { ScreenHeaderButton } from "./ScreenHeaderButton";
56import { NativeHeaderToolbar, NativeStackScreenOptions } from "../native/StackHeader";
......@@ -139,14 +140,25 @@ export function ScreenHeader(props: ScreenHeaderProps) {
139140 props.trailing) &&
140141 props.options?.unstable_headerRightItems === undefined ? (
141142 <NativeHeaderToolbar placement="right">
142 {props.actions?.map((action) => (
143 <ScreenHeaderButton
144 key={action.accessibilityLabel}
145 {...action}
146 icon={iosIcon(action.icon)}
147 separateBackground
148 />
149 ))}
143 {props.actions?.map(({ loading, ...action }) =>
144 loading ? (
145 <NativeHeaderToolbar.Custom key={action.accessibilityLabel}>
146 <View
147 accessibilityLabel={action.accessibilityLabel}
148 className="size-9 items-center justify-center"
149 >
150 <ActivityIndicator color={action.tintColor} />
151 </View>
152 </NativeHeaderToolbar.Custom>
153 ) : (
154 <ScreenHeaderButton
155 key={action.accessibilityLabel}
156 {...action}
157 icon={iosIcon(action.icon)}
158 separateBackground
159 />
160 ),
161 )}
150162 {refresh && !compactSearch ? (
151163 <ScreenHeaderButton
152164 accessibilityLabel={search?.refreshAccessibilityLabel}
apps/mobile/src/components/ScreenHeader.types.ts+2
......@@ -8,6 +8,8 @@ export interface ScreenHeaderAction {
88 readonly icon: AppSymbolName;
99 readonly onPress: () => void;
1010 readonly disabled?: boolean;
11 /** Shows a spinner in place of the icon while the action's work is pending. */
12 readonly loading?: boolean;
1113 readonly selected?: boolean;
1214 readonly tintColor?: ColorValue;
1315}
apps/mobile/src/features/connection/ConnectionSheetButton.tsx deleted-111
......@@ -1,111 +0,0 @@
1import { SymbolView } from "../../components/AppSymbol";
2import { Platform, Pressable } from "react-native";
3
4import { AppText as Text } from "../../components/AppText";
5import { cn } from "../../lib/cn";
6import { MaterialButton } from "../../components/MaterialButton";
7
8const CARD_SHADOW = Platform.select({
9 ios: {
10 shadowColor: "rgba(23,23,23,0.08)",
11 shadowOffset: { width: 0, height: 4 },
12 shadowOpacity: 1,
13 shadowRadius: 16,
14 },
15 android: { elevation: 3 },
16});
17
18const CARD_SHADOW_DARK = Platform.select({
19 ios: {
20 shadowColor: "#000",
21 shadowOffset: { width: 0, height: 2 },
22 shadowOpacity: 0.18,
23 shadowRadius: 8,
24 },
25 android: { elevation: 4 },
26});
27
28export { CARD_SHADOW, CARD_SHADOW_DARK };
29
30export function ConnectionSheetButton(props: {
31 readonly icon: React.ComponentProps<typeof SymbolView>["name"];
32 readonly label: string;
33 readonly disabled?: boolean;
34 readonly tone?: "primary" | "secondary" | "danger";
35 readonly compact?: boolean;
36 readonly fullWidth?: boolean;
37 readonly onPress: () => void;
38}) {
39 if (Platform.OS === "android")
40 return (
41 <MaterialButton
42 label={props.label}
43 disabled={props.disabled}
44 onPress={props.onPress}
45 tone={props.tone}
46 fullWidth={props.fullWidth}
47 />
48 );
49 const tone = props.tone ?? "secondary";
50
51 const textColorClassName =
52 tone === "primary"
53 ? "accent-primary-foreground"
54 : tone === "danger"
55 ? "accent-danger-foreground"
56 : "accent-secondary-foreground";
57
58 const primaryShadow =
59 tone === "primary"
60 ? Platform.select({
61 ios: {
62 shadowColor: "#000",
63 shadowOffset: { width: 0, height: 3 },
64 shadowOpacity: 0.14,
65 shadowRadius: 6,
66 },
67 android: { elevation: 3 },
68 })
69 : undefined;
70
71 return (
72 <Pressable
73 accessibilityLabel={props.label}
74 accessibilityRole="button"
75 accessibilityState={{ disabled: props.disabled ?? false }}
76 className={cn(
77 props.compact
78 ? "min-h-[42px] flex-row items-center justify-center gap-1.5 rounded-[14px] px-3.5 py-2.5"
79 : "min-h-[48px] flex-row items-center justify-center gap-2 rounded-[16px] px-4 py-3",
80 "disabled:opacity-50",
81 tone === "primary"
82 ? "bg-primary"
83 : tone === "danger"
84 ? "border border-danger-border bg-danger"
85 : "border border-border bg-secondary",
86 )}
87 disabled={props.disabled}
88 onPress={props.onPress}
89 style={primaryShadow}
90 >
91 <SymbolView
92 name={props.icon}
93 size={props.compact ? 13 : 14}
94 tintColorClassName={textColorClassName}
95 type="monochrome"
96 />
97 <Text
98 className={cn(
99 "text-xs font-t3-bold tracking-[0.8px] uppercase",
100 tone === "primary"
101 ? "text-primary-foreground"
102 : tone === "danger"
103 ? "text-danger-foreground"
104 : "text-secondary-foreground",
105 )}
106 >
107 {props.label}
108 </Text>
109 </Pressable>
110 );
111}
apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx+265-96
......@@ -1,7 +1,10 @@
11import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
2import { useAuth } from "@clerk/expo";
23import { CameraView, useCameraPermissions } from "expo-camera";
4import * as Haptics from "expo-haptics";
35import {
46 StackActions,
7 useIsFocused,
58 useNavigation,
69 useRoute,
710 type StaticScreenProps,
......@@ -9,16 +12,24 @@ import {
912import type { EnvironmentId } from "@t3tools/contracts";
1013import { AsyncResult } from "effect/reactivity";
1114import { useCallback, useEffect, useRef, useState } from "react";
12import { Alert, Linking, Platform, View } from "react-native";
15import { Alert, Linking, Pressable, StyleSheet, View } from "react-native";
16import Svg, { Defs, RadialGradient, Rect, Stop } from "react-native-svg";
1317import { useSafeAreaInsets } from "react-native-safe-area-context";
18import { cn } from "../../lib/cn";
1419import { useUniwindTheme } from "../../lib/useUniwindTheme";
20import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
1521import { SettingsScreen } from "../settings/components/SettingsScreen";
16import { AppText as Text } from "../../components/AppText";
17import { ErrorBanner } from "../../components/ErrorBanner";
18import { ConnectionFormField } from "./ConnectionFormField";
19import { ConnectionSheetButton } from "./ConnectionSheetButton";
22import { AppText as Text, AppTextInput, type AppTextInputProps } from "../../components/AppText";
23import { FrostedCutout } from "../../components/FrostedCutout";
2024import { buildPairingUrl, extractPairingUrlFromQrPayload, parsePairingUrl } from "./pairing";
21import { useRemoteConnections } from "../../state/use-remote-environment-registry";
25import {
26 setPendingConnectionError,
27 useRemoteConnections,
28} from "../../state/use-remote-environment-registry";
29import { SymbolView } from "../../components/AppSymbol";
30import { hasCloudPublicConfig } from "../cloud/publicConfig";
31import { CloudEnvironmentRows } from "./CloudEnvironmentRows";
32import { splitEnvironmentSections } from "./environmentSections";
2233
2334type ConnectionsNewRouteParams = {
2435 readonly mode?: string;
......@@ -51,9 +62,11 @@ export function ConnectionsNewRouteScreen({
5162 const [hostInput, setHostInput] = useState("");
5263 const [codeInput, setCodeInput] = useState("");
5364 const [isSubmitting, setIsSubmitting] = useState(false);
54 const [showScanner, setShowScanner] = useState(params.mode === "scan_qr");
5565 const [cameraPermission, requestCameraPermission] = useCameraPermissions();
66 const screenFocused = useIsFocused();
5667 const [scannerLocked, setScannerLocked] = useState(false);
68 // A good scan parks the camera so it can't re-read the code over later edits.
69 const [scanComplete, setScanComplete] = useState(false);
5770 const attemptedAutoConnectRef = useRef<string | null>(null);
5871
5972 const headerIconColor = useUniwindTheme()["--color-icon"];
......@@ -84,34 +97,25 @@ export function ConnectionsNewRouteScreen({
8497
8598 const handleHostChange = useCallback((value: string) => {
8699 setHostInput(value);
100 setPendingConnectionError(null);
87101 }, []);
88102
89103 const handleCodeChange = useCallback((value: string) => {
90104 setCodeInput(value);
105 setPendingConnectionError(null);
91106 }, []);
92107
93 const openScanner = useCallback(async () => {
94 if (cameraPermission?.granted) {
95 setScannerLocked(false);
96 setShowScanner(true);
97 return;
98 }
108 // The error is shared app state; a stale failure must not greet the next visit.
109 useEffect(() => () => setPendingConnectionError(null), []);
99110
111 // Opening the sheet never prompts; a tap on the scan card asks for the camera.
112 const handleScanPress = useCallback(async () => {
100113 const permission = await requestCameraPermission();
101114 if (permission.granted) {
102 setScannerLocked(false);
103 setShowScanner(true);
104 return;
105 }
106
107 if (permission.canAskAgain) {
108 Alert.alert(
109 "Camera access needed",
110 "Allow camera access to scan an environment pairing QR code.",
111 );
115 setScanComplete(false);
112116 return;
113117 }
114
118 if (permission.canAskAgain) return;
115119 Alert.alert(
116120 "Camera access needed",
117121 "Camera access was denied for this app. Open Settings to enable it.",
......@@ -120,12 +124,7 @@ export function ConnectionsNewRouteScreen({
120124 { text: "Open Settings", onPress: () => void Linking.openSettings() },
121125 ],
122126 );
123 }, [cameraPermission?.granted, requestCameraPermission]);
124
125 const closeScanner = useCallback(() => {
126 setShowScanner(false);
127 setScannerLocked(false);
128 }, []);
127 }, [requestCameraPermission]);
129128
130129 const handleQrScan = useCallback(
131130 ({ data }: { readonly data: string }) => {
......@@ -141,13 +140,14 @@ export function ConnectionsNewRouteScreen({
141140 setHostInput(host);
142141 setCodeInput(code);
143142 onChangeConnectionPairingUrl(pairingUrl);
144 setShowScanner(false);
143 setScanComplete(true);
144 setScannerLocked(false);
145145 } catch (error) {
146 void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
146147 Alert.alert(
147148 "Invalid QR code",
148149 error instanceof Error ? error.message : "Scanned QR code was not recognized.",
149150 );
150 } finally {
151151 setTimeout(() => {
152152 setScannerLocked(false);
153153 }, 600);
......@@ -162,6 +162,11 @@ export function ConnectionsNewRouteScreen({
162162 onChangeConnectionPairingUrl(pairingUrl);
163163 try {
164164 const result = await onConnectPress(pairingUrl, params.routeFor);
165 void Haptics.notificationAsync(
166 AsyncResult.isSuccess(result)
167 ? Haptics.NotificationFeedbackType.Success
168 : Haptics.NotificationFeedbackType.Error,
169 );
165170 if (AsyncResult.isSuccess(result)) {
166171 if (replaceWithHome || !navigation.canGoBack()) {
167172 navigation.dispatch(StackActions.replace("Home"));
......@@ -192,96 +197,260 @@ export function ConnectionsNewRouteScreen({
192197 return (
193198 <SettingsScreen
194199 formSheet={routeName === "ConnectionsNew"}
195 title={showScanner ? "Scan QR Code" : "Add Environment"}
200 title="Add environment"
196201 actions={[
197202 {
198 accessibilityLabel: showScanner ? "Close scanner" : "Scan QR code",
199 icon: showScanner ? "xmark" : Platform.OS === "ios" ? "qrcode.viewfinder" : "camera",
203 accessibilityLabel: isSubmitting ? "Connecting" : "Add environment",
204 loading: isSubmitting,
205 icon: "checkmark",
200206 tintColor: headerIconColor,
207 disabled: connectDisabled,
201208 onPress: () => {
202 if (showScanner) {
203 closeScanner();
204 } else {
205 void openScanner();
206 }
209 void handleSubmit();
207210 },
208211 },
209212 ]}
210213 >
211214 <ScrollView
212215 contentInsetAdjustmentBehavior="automatic"
216 keyboardShouldPersistTaps="handled"
213217 showsVerticalScrollIndicator={false}
214218 className="flex-1"
215219 contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }}
216220 contentContainerStyle={{
217 paddingHorizontal: 20,
218 paddingTop: 16,
221 paddingHorizontal: 16,
222 paddingTop: 4,
219223 }}
220224 >
221 <View collapsable={false} className="gap-5">
222 {showScanner ? (
223 cameraPermission?.granted ? (
224 <View className="overflow-hidden rounded-[24px] border-continuous">
225 <CameraView
226 barcodeScannerSettings={{ barcodeTypes: ["qr"] }}
227 onBarcodeScanned={handleQrScan}
228 style={{ aspectRatio: 1, width: "100%" }}
229 />
230 </View>
231 ) : (
232 <View className="items-center gap-3 rounded-[24px] border-continuous bg-grouped-card px-5 py-8">
233 <Text className="text-center text-sm leading-normal text-foreground-muted">
234 Camera permission is required to scan a QR code.
235 </Text>
236 <ConnectionSheetButton
237 compact
238 icon="camera"
239 label="Allow camera"
240 tone="secondary"
241 onPress={() => {
242 void openScanner();
243 }}
244 />
245 </View>
246 )
247 ) : (
248 <View collapsable={false} className="gap-4 rounded-[24px] bg-grouped-card p-4">
249 <ConnectionFormField
250 label="Host"
251 autoCapitalize="none"
252 autoCorrect={false}
225 <View collapsable={false} className="gap-6.5">
226 <PairingScanCard
227 cameraActive={cameraPermission?.granted === true && screenFocused && !scanComplete}
228 onScan={handleQrScan}
229 onScanPress={() => {
230 void handleScanPress();
231 }}
232 />
233
234 <View collapsable={false} className="gap-2">
235 <Text className="px-4 text-sm font-t3-medium text-foreground-muted">Or enter it</Text>
236 <View
237 collapsable={false}
238 className={cn(
239 "overflow-hidden rounded-[26px] border-continuous border bg-grouped-card",
240 pairingConnectionError ? "border-danger-foreground" : "border-transparent",
241 )}
242 >
243 <PairingInputRow
244 label="Address"
253245 keyboardType="url"
254 placeholder="192.168.1.100:8080"
246 placeholder="192.168.1.100:3773"
255247 value={hostInput}
256248 onChangeText={handleHostChange}
257249 />
258
259 <ConnectionFormField
260 label="Pairing code"
261 autoCapitalize="none"
262 autoCorrect={false}
263 placeholder="abc-123-xyz"
250 <View className="ml-4 border-t border-border-subtle" />
251 <PairingInputRow
252 label="Code"
253 placeholder="Pairing code"
254 returnKeyType="go"
264255 value={codeInput}
265256 onChangeText={handleCodeChange}
257 onSubmitEditing={() => {
258 if (!connectDisabled) void handleSubmit();
259 }}
266260 />
267
268 {pairingConnectionError ? <ErrorBanner message={pairingConnectionError} /> : null}
269
270 <View className="android:flex-row android:justify-end">
271 <ConnectionSheetButton
272 icon="plus"
273 label={isSubmitting ? "Pairing..." : "Add environment"}
274 disabled={connectDisabled}
275 tone="primary"
276 onPress={() => {
277 void handleSubmit();
278 }}
279 />
280 </View>
281261 </View>
282 )}
262 <Text
263 accessibilityLiveRegion="polite"
264 className={cn(
265 "px-4 text-sm leading-normal",
266 pairingConnectionError ? "text-danger-foreground" : "text-foreground-muted",
267 )}
268 >
269 {pairingConnectionError ??
270 "For machines on your local network or tailnet. The machine keeps its own provider credentials."}
271 </Text>
272 </View>
273
274 {hasCloudPublicConfig() ? <T3ConnectSection /> : null}
283275 </View>
284276 </ScrollView>
285277 </SettingsScreen>
286278 );
287279}
280
281const SCAN_CARD_HEIGHT = 250;
282const SCAN_RETICLE_SIZE = 150;
283const SCAN_RETICLE_TOP = 36;
284const SCAN_RETICLE_RADIUS = 28;
285
286/**
287 * Live QR scanner: the camera fills the card, blurred everywhere except the
288 * reticle so the code being aimed at stays sharp. Without a camera it shows the
289 * same frame over a dark gradient.
290 */
291function PairingScanCard(props: {
292 readonly cameraActive: boolean;
293 readonly onScan: (result: { readonly data: string }) => void;
294 readonly onScanPress: () => void;
295}) {
296 const light = useAppearancePreferences().themeAppearance === "light";
297 return (
298 <Pressable
299 accessibilityRole={props.cameraActive ? undefined : "button"}
300 accessibilityLabel={props.cameraActive ? undefined : "Scan QR code"}
301 disabled={props.cameraActive}
302 onPress={props.onScanPress}
303 className={cn(
304 "overflow-hidden rounded-[26px] border-continuous",
305 light ? "bg-white" : "bg-black",
306 )}
307 style={{ height: SCAN_CARD_HEIGHT }}
308 >
309 <Svg accessibilityElementsHidden height="100%" style={StyleSheet.absoluteFill} width="100%">
310 <Defs>
311 <RadialGradient id="pairing-scan-backdrop" cx="50%" cy="50%" r="50%">
312 <Stop offset="0%" stopColor={light ? "#f4f4f5" : "#2e2e2e"} />
313 <Stop offset="100%" stopColor={light ? "#d9d9dc" : "#0e0e0e"} />
314 </RadialGradient>
315 </Defs>
316 <Rect fill="url(#pairing-scan-backdrop)" height="100%" width="100%" />
317 </Svg>
318
319 {props.cameraActive ? (
320 <>
321 <CameraView
322 active
323 barcodeScannerSettings={{ barcodeTypes: ["qr"] }}
324 onBarcodeScanned={props.onScan}
325 style={StyleSheet.absoluteFill}
326 />
327 <FrostedCutout
328 cutoutTop={SCAN_RETICLE_TOP}
329 cutoutWidth={SCAN_RETICLE_SIZE}
330 cutoutHeight={SCAN_RETICLE_SIZE}
331 cutoutRadius={SCAN_RETICLE_RADIUS}
332 appearance={light ? "light" : "dark"}
333 />
334 </>
335 ) : null}
336
337 <View
338 pointerEvents="none"
339 className="absolute inset-x-0 items-center gap-3.5"
340 style={{ top: SCAN_RETICLE_TOP }}
341 >
342 <View
343 className={cn(
344 "items-center justify-center gap-2 border-continuous border-[3px]",
345 light ? "border-black/70" : "border-white/80",
346 )}
347 style={{
348 borderRadius: SCAN_RETICLE_RADIUS,
349 height: SCAN_RETICLE_SIZE,
350 width: SCAN_RETICLE_SIZE,
351 }}
352 >
353 {props.cameraActive ? null : (
354 <>
355 <SymbolView
356 name="qrcode.viewfinder"
357 size={44}
358 tintColorClassName={light ? "accent-black/60" : "accent-white/70"}
359 type="monochrome"
360 weight="light"
361 />
362 <Text
363 className={cn("font-t3-medium text-sm", light ? "text-black/70" : "text-white/80")}
364 >
365 Tap to scan
366 </Text>
367 </>
368 )}
369 </View>
370 <Text className={cn("text-center text-sm", light ? "text-black/70" : "text-white/80")}>
371 Scan the code from t3 pair or desktop Connections settings
372 </Text>
373 </View>
374 </Pressable>
375 );
376}
377
378/**
379 * Managed-relay alternative to manual pairing: signed in, the account's
380 * published environments connect with a switch; signed out, one row opens the
381 * T3 Account sheet.
382 */
383function T3ConnectSection() {
384 const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false });
385 const navigation = useNavigation();
386 const { connectedEnvironments, onSetEnvironmentEnabled, onRemoveEnvironmentPress } =
387 useRemoteConnections();
388 const { connectedCloudEnvironments } = splitEnvironmentSections({
389 connectedEnvironments,
390 cloudEnvironments: null,
391 });
392
393 return (
394 <View collapsable={false} className="gap-2">
395 <Text className="px-4 text-sm font-t3-medium text-foreground-muted">
396 Or use the managed relay
397 </Text>
398 {isSignedIn ? (
399 <CloudEnvironmentRows
400 connectedCloudEnvironments={connectedCloudEnvironments}
401 onSetEnvironmentEnabled={onSetEnvironmentEnabled}
402 onRemoveEnvironment={onRemoveEnvironmentPress}
403 showHeader={false}
404 />
405 ) : (
406 <Pressable
407 accessibilityRole="button"
408 disabled={!isLoaded}
409 onPress={() => navigation.navigate("SettingsSheet", { screen: "SettingsAuth" })}
410 className="min-h-13 flex-row items-center gap-3 rounded-[26px] border-continuous bg-grouped-card px-4 active:opacity-70"
411 >
412 <View className="min-w-0 flex-1 py-3">
413 <Text className="text-base text-foreground">Sign in to T3 Connect</Text>
414 <Text className="text-sm text-foreground-muted">
415 Reach your machines from anywhere, no network setup.
416 </Text>
417 </View>
418 <SymbolView
419 name="chevron.right"
420 size={14}
421 tintColorClassName="accent-chevron"
422 type="monochrome"
423 weight="semibold"
424 />
425 </Pressable>
426 )}
427 </View>
428 );
429}
430
431/** Inline grouped-list field: fixed label column, input filling the rest. */
432function PairingInputRow({
433 label,
434 ...inputProps
435}: Omit<AppTextInputProps, "accessibilityLabel" | "className"> & {
436 readonly label: string;
437}) {
438 return (
439 <View collapsable={false} className="h-13 flex-row items-center gap-3 px-4">
440 <Text
441 accessibilityElementsHidden
442 importantForAccessibility="no-hide-descendants"
443 className="w-20 text-base leading-[23px] text-foreground-muted"
444 >
445 {label}
446 </Text>
447 <AppTextInput
448 {...inputProps}
449 accessibilityLabel={label}
450 autoCapitalize="none"
451 autoCorrect={false}
452 className="min-h-0 flex-1 rounded-none border-0 bg-transparent p-0 text-base leading-[23px]"
453 />
454 </View>
455 );
456}
apps/mobile/src/native/NativeHeaderToolbar.tsx+11
......@@ -118,6 +118,10 @@ function convertToolbarChild(child: ReactNode): NativeStackHeaderItem | null {
118118 }
119119
120120 const typeName = elementTypeName(child);
121 if (typeName === "NativeHeaderToolbarCustom" && isValidElement(child.props.children)) {
122 return { type: "custom", element: child.props.children };
123 }
124
121125 if (typeName === "NativeHeaderToolbarButton") {
122126 return {
123127 type: "button",
......@@ -263,6 +267,12 @@ function NativeHeaderToolbarMenuAction(_props: {
263267}
264268NativeHeaderToolbarMenuAction.displayName = "NativeHeaderToolbarMenuAction";
265269
270/** Renders an arbitrary element as a bar item, e.g. a spinner standing in for a button. */
271function NativeHeaderToolbarCustom(_props: { readonly children: ReactElement }) {
272 return null;
273}
274NativeHeaderToolbarCustom.displayName = "NativeHeaderToolbarCustom";
275
266276function NativeHeaderToolbarLabel(_props: { readonly children?: ReactNode }) {
267277 return null;
268278}
......@@ -284,6 +294,7 @@ NativeHeaderToolbarSearchBarSlot.displayName = "NativeHeaderToolbarSearchBarSlot
284294
285295export const NativeHeaderToolbar = Object.assign(NativeHeaderToolbarRoot, {
286296 Button: NativeHeaderToolbarButton,
297 Custom: NativeHeaderToolbarCustom,
287298 Label: NativeHeaderToolbarLabel,
288299 Menu: Object.assign(NativeHeaderToolbarMenu, {
289300 Action: NativeHeaderToolbarMenuAction,