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 {...@@ -54,6 +54,15 @@ public final class T3NativeControlsModule: Module {
54 }54 }
55 }55 }
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
57 View(T3ContextSheetSizeView.self) {66 View(T3ContextSheetSizeView.self) {
58 ViewName("ContextSheetSize")67 ViewName("ContextSheetSize")
59 Prop("contentHeight") { (view: T3ContextSheetSizeView, height: Double) in68 Prop("contentHeight") { (view: T3ContextSheetSizeView, height: Double) in
apps/mobile/src/components/AndroidScreenHeader.tsx+22-11
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import { useState, type ReactNode } from "react";1import { useState, type ReactNode } from "react";
2import { View } from "react-native";2import { ActivityIndicator, View } from "react-native";
33
4import type { AppSymbolName } from "./AppSymbol";4import type { AppSymbolName } from "./AppSymbol";
5import { AppText as Text } from "./AppText";5import { AppText as Text } from "./AppText";
...@@ -14,6 +14,7 @@ export interface AndroidHeaderAction {...@@ -14,6 +14,7 @@ export interface AndroidHeaderAction {
14 readonly icon: AppSymbolName;14 readonly icon: AppSymbolName;
15 readonly onPress: () => void;15 readonly onPress: () => void;
16 readonly disabled?: boolean;16 readonly disabled?: boolean;
17 readonly loading?: boolean;
17 readonly selected?: boolean;18 readonly selected?: boolean;
18}19}
1920
...@@ -84,16 +85,26 @@ export function AndroidScreenHeader(props: {...@@ -84,16 +85,26 @@ export function AndroidScreenHeader(props: {
84 ) : null}85 ) : null}
85 </View>86 </View>
8687
87 {visibleActions.map((action) => (88 {visibleActions.map((action) =>
88 <AndroidHeaderIconButton89 action.loading ? (
89 key={action.accessibilityLabel}90 <View
90 accessibilityLabel={action.accessibilityLabel}91 key={action.accessibilityLabel}
91 disabled={action.disabled}92 accessibilityLabel={action.accessibilityLabel}
92 selected={action.selected}93 className="size-12 items-center justify-center"
93 icon={action.icon}94 >
94 onPress={action.onPress}95 <ActivityIndicator colorClassName="accent-header-foreground" />
95 />96 </View>
96 ))}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 )}
97 {overflowActions.length > 0 ? (108 {overflowActions.length > 0 ? (
98 <AndroidAnchoredMenu109 <AndroidAnchoredMenu
99 actions={overflowActions.map((action, index) => ({110 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 @@...@@ -1,5 +1,6 @@
1import type { HeaderBarButtonMailSearchToolbarItem } from "react-native-screens";1import type { HeaderBarButtonMailSearchToolbarItem } from "react-native-screens";
2import { useId } from "react";2import { useId } from "react";
3import { ActivityIndicator, View } from "react-native";
3import { createNativeHeaderMenu } from "./nativeHeaderMenu.ios";4import { createNativeHeaderMenu } from "./nativeHeaderMenu.ios";
4import { ScreenHeaderButton } from "./ScreenHeaderButton";5import { ScreenHeaderButton } from "./ScreenHeaderButton";
5import { NativeHeaderToolbar, NativeStackScreenOptions } from "../native/StackHeader";6import { NativeHeaderToolbar, NativeStackScreenOptions } from "../native/StackHeader";
...@@ -139,14 +140,25 @@ export function ScreenHeader(props: ScreenHeaderProps) {...@@ -139,14 +140,25 @@ export function ScreenHeader(props: ScreenHeaderProps) {
139 props.trailing) &&140 props.trailing) &&
140 props.options?.unstable_headerRightItems === undefined ? (141 props.options?.unstable_headerRightItems === undefined ? (
141 <NativeHeaderToolbar placement="right">142 <NativeHeaderToolbar placement="right">
142 {props.actions?.map((action) => (143 {props.actions?.map(({ loading, ...action }) =>
143 <ScreenHeaderButton144 loading ? (
144 key={action.accessibilityLabel}145 <NativeHeaderToolbar.Custom key={action.accessibilityLabel}>
145 {...action}146 <View
146 icon={iosIcon(action.icon)}147 accessibilityLabel={action.accessibilityLabel}
147 separateBackground148 className="size-9 items-center justify-center"
148 />149 >
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 )}
150 {refresh && !compactSearch ? (162 {refresh && !compactSearch ? (
151 <ScreenHeaderButton163 <ScreenHeaderButton
152 accessibilityLabel={search?.refreshAccessibilityLabel}164 accessibilityLabel={search?.refreshAccessibilityLabel}
apps/mobile/src/components/ScreenHeader.types.ts+2
...@@ -8,6 +8,8 @@ export interface ScreenHeaderAction {...@@ -8,6 +8,8 @@ export interface ScreenHeaderAction {
8 readonly icon: AppSymbolName;8 readonly icon: AppSymbolName;
9 readonly onPress: () => void;9 readonly onPress: () => void;
10 readonly disabled?: boolean;10 readonly disabled?: boolean;
11 /** Shows a spinner in place of the icon while the action's work is pending. */
12 readonly loading?: boolean;
11 readonly selected?: boolean;13 readonly selected?: boolean;
12 readonly tintColor?: ColorValue;14 readonly tintColor?: ColorValue;
13}15}
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 @@...@@ -1,7 +1,10 @@
1import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";1import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
2import { useAuth } from "@clerk/expo";
2import { CameraView, useCameraPermissions } from "expo-camera";3import { CameraView, useCameraPermissions } from "expo-camera";
4import * as Haptics from "expo-haptics";
3import {5import {
4 StackActions,6 StackActions,
7 useIsFocused,
5 useNavigation,8 useNavigation,
6 useRoute,9 useRoute,
7 type StaticScreenProps,10 type StaticScreenProps,
...@@ -9,16 +12,24 @@ import {...@@ -9,16 +12,24 @@ import {
9import type { EnvironmentId } from "@t3tools/contracts";12import type { EnvironmentId } from "@t3tools/contracts";
10import { AsyncResult } from "effect/reactivity";13import { AsyncResult } from "effect/reactivity";
11import { useCallback, useEffect, useRef, useState } from "react";14import { 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";
13import { useSafeAreaInsets } from "react-native-safe-area-context";17import { useSafeAreaInsets } from "react-native-safe-area-context";
18import { cn } from "../../lib/cn";
14import { useUniwindTheme } from "../../lib/useUniwindTheme";19import { useUniwindTheme } from "../../lib/useUniwindTheme";
20import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
15import { SettingsScreen } from "../settings/components/SettingsScreen";21import { SettingsScreen } from "../settings/components/SettingsScreen";
16import { AppText as Text } from "../../components/AppText";22import { AppText as Text, AppTextInput, type AppTextInputProps } from "../../components/AppText";
17import { ErrorBanner } from "../../components/ErrorBanner";23import { FrostedCutout } from "../../components/FrostedCutout";
18import { ConnectionFormField } from "./ConnectionFormField";
19import { ConnectionSheetButton } from "./ConnectionSheetButton";
20import { buildPairingUrl, extractPairingUrlFromQrPayload, parsePairingUrl } from "./pairing";24import { 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
23type ConnectionsNewRouteParams = {34type ConnectionsNewRouteParams = {
24 readonly mode?: string;35 readonly mode?: string;
...@@ -51,9 +62,11 @@ export function ConnectionsNewRouteScreen({...@@ -51,9 +62,11 @@ export function ConnectionsNewRouteScreen({
51 const [hostInput, setHostInput] = useState("");62 const [hostInput, setHostInput] = useState("");
52 const [codeInput, setCodeInput] = useState("");63 const [codeInput, setCodeInput] = useState("");
53 const [isSubmitting, setIsSubmitting] = useState(false);64 const [isSubmitting, setIsSubmitting] = useState(false);
54 const [showScanner, setShowScanner] = useState(params.mode === "scan_qr");
55 const [cameraPermission, requestCameraPermission] = useCameraPermissions();65 const [cameraPermission, requestCameraPermission] = useCameraPermissions();
66 const screenFocused = useIsFocused();
56 const [scannerLocked, setScannerLocked] = useState(false);67 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);
57 const attemptedAutoConnectRef = useRef<string | null>(null);70 const attemptedAutoConnectRef = useRef<string | null>(null);
5871
59 const headerIconColor = useUniwindTheme()["--color-icon"];72 const headerIconColor = useUniwindTheme()["--color-icon"];
...@@ -84,34 +97,25 @@ export function ConnectionsNewRouteScreen({...@@ -84,34 +97,25 @@ export function ConnectionsNewRouteScreen({
8497
85 const handleHostChange = useCallback((value: string) => {98 const handleHostChange = useCallback((value: string) => {
86 setHostInput(value);99 setHostInput(value);
100 setPendingConnectionError(null);
87 }, []);101 }, []);
88102
89 const handleCodeChange = useCallback((value: string) => {103 const handleCodeChange = useCallback((value: string) => {
90 setCodeInput(value);104 setCodeInput(value);
105 setPendingConnectionError(null);
91 }, []);106 }, []);
92107
93 const openScanner = useCallback(async () => {108 // The error is shared app state; a stale failure must not greet the next visit.
94 if (cameraPermission?.granted) {109 useEffect(() => () => setPendingConnectionError(null), []);
95 setScannerLocked(false);
96 setShowScanner(true);
97 return;
98 }
99110
111 // Opening the sheet never prompts; a tap on the scan card asks for the camera.
112 const handleScanPress = useCallback(async () => {
100 const permission = await requestCameraPermission();113 const permission = await requestCameraPermission();
101 if (permission.granted) {114 if (permission.granted) {
102 setScannerLocked(false);115 setScanComplete(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 );
112 return;116 return;
113 }117 }
114118 if (permission.canAskAgain) return;
115 Alert.alert(119 Alert.alert(
116 "Camera access needed",120 "Camera access needed",
117 "Camera access was denied for this app. Open Settings to enable it.",121 "Camera access was denied for this app. Open Settings to enable it.",
...@@ -120,12 +124,7 @@ export function ConnectionsNewRouteScreen({...@@ -120,12 +124,7 @@ export function ConnectionsNewRouteScreen({
120 { text: "Open Settings", onPress: () => void Linking.openSettings() },124 { text: "Open Settings", onPress: () => void Linking.openSettings() },
121 ],125 ],
122 );126 );
123 }, [cameraPermission?.granted, requestCameraPermission]);127 }, [requestCameraPermission]);
124
125 const closeScanner = useCallback(() => {
126 setShowScanner(false);
127 setScannerLocked(false);
128 }, []);
129128
130 const handleQrScan = useCallback(129 const handleQrScan = useCallback(
131 ({ data }: { readonly data: string }) => {130 ({ data }: { readonly data: string }) => {
...@@ -141,13 +140,14 @@ export function ConnectionsNewRouteScreen({...@@ -141,13 +140,14 @@ export function ConnectionsNewRouteScreen({
141 setHostInput(host);140 setHostInput(host);
142 setCodeInput(code);141 setCodeInput(code);
143 onChangeConnectionPairingUrl(pairingUrl);142 onChangeConnectionPairingUrl(pairingUrl);
144 setShowScanner(false);143 setScanComplete(true);
144 setScannerLocked(false);
145 } catch (error) {145 } catch (error) {
146 void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
146 Alert.alert(147 Alert.alert(
147 "Invalid QR code",148 "Invalid QR code",
148 error instanceof Error ? error.message : "Scanned QR code was not recognized.",149 error instanceof Error ? error.message : "Scanned QR code was not recognized.",
149 );150 );
150 } finally {
151 setTimeout(() => {151 setTimeout(() => {
152 setScannerLocked(false);152 setScannerLocked(false);
153 }, 600);153 }, 600);
...@@ -162,6 +162,11 @@ export function ConnectionsNewRouteScreen({...@@ -162,6 +162,11 @@ export function ConnectionsNewRouteScreen({
162 onChangeConnectionPairingUrl(pairingUrl);162 onChangeConnectionPairingUrl(pairingUrl);
163 try {163 try {
164 const result = await onConnectPress(pairingUrl, params.routeFor);164 const result = await onConnectPress(pairingUrl, params.routeFor);
165 void Haptics.notificationAsync(
166 AsyncResult.isSuccess(result)
167 ? Haptics.NotificationFeedbackType.Success
168 : Haptics.NotificationFeedbackType.Error,
169 );
165 if (AsyncResult.isSuccess(result)) {170 if (AsyncResult.isSuccess(result)) {
166 if (replaceWithHome || !navigation.canGoBack()) {171 if (replaceWithHome || !navigation.canGoBack()) {
167 navigation.dispatch(StackActions.replace("Home"));172 navigation.dispatch(StackActions.replace("Home"));
...@@ -192,96 +197,260 @@ export function ConnectionsNewRouteScreen({...@@ -192,96 +197,260 @@ export function ConnectionsNewRouteScreen({
192 return (197 return (
193 <SettingsScreen198 <SettingsScreen
194 formSheet={routeName === "ConnectionsNew"}199 formSheet={routeName === "ConnectionsNew"}
195 title={showScanner ? "Scan QR Code" : "Add Environment"}200 title="Add environment"
196 actions={[201 actions={[
197 {202 {
198 accessibilityLabel: showScanner ? "Close scanner" : "Scan QR code",203 accessibilityLabel: isSubmitting ? "Connecting" : "Add environment",
199 icon: showScanner ? "xmark" : Platform.OS === "ios" ? "qrcode.viewfinder" : "camera",204 loading: isSubmitting,
205 icon: "checkmark",
200 tintColor: headerIconColor,206 tintColor: headerIconColor,
207 disabled: connectDisabled,
201 onPress: () => {208 onPress: () => {
202 if (showScanner) {209 void handleSubmit();
203 closeScanner();
204 } else {
205 void openScanner();
206 }
207 },210 },
208 },211 },
209 ]}212 ]}
210 >213 >
211 <ScrollView214 <ScrollView
212 contentInsetAdjustmentBehavior="automatic"215 contentInsetAdjustmentBehavior="automatic"
216 keyboardShouldPersistTaps="handled"
213 showsVerticalScrollIndicator={false}217 showsVerticalScrollIndicator={false}
214 className="flex-1"218 className="flex-1"
215 contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }}219 contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }}
216 contentContainerStyle={{220 contentContainerStyle={{
217 paddingHorizontal: 20,221 paddingHorizontal: 16,
218 paddingTop: 16,222 paddingTop: 4,
219 }}223 }}
220 >224 >
221 <View collapsable={false} className="gap-5">225 <View collapsable={false} className="gap-6.5">
222 {showScanner ? (226 <PairingScanCard
223 cameraPermission?.granted ? (227 cameraActive={cameraPermission?.granted === true && screenFocused && !scanComplete}
224 <View className="overflow-hidden rounded-[24px] border-continuous">228 onScan={handleQrScan}
225 <CameraView229 onScanPress={() => {
226 barcodeScannerSettings={{ barcodeTypes: ["qr"] }}230 void handleScanPress();
227 onBarcodeScanned={handleQrScan}231 }}
228 style={{ aspectRatio: 1, width: "100%" }}232 />
229 />233
230 </View>234 <View collapsable={false} className="gap-2">
231 ) : (235 <Text className="px-4 text-sm font-t3-medium text-foreground-muted">Or enter it</Text>
232 <View className="items-center gap-3 rounded-[24px] border-continuous bg-grouped-card px-5 py-8">236 <View
233 <Text className="text-center text-sm leading-normal text-foreground-muted">237 collapsable={false}
234 Camera permission is required to scan a QR code.238 className={cn(
235 </Text>239 "overflow-hidden rounded-[26px] border-continuous border bg-grouped-card",
236 <ConnectionSheetButton240 pairingConnectionError ? "border-danger-foreground" : "border-transparent",
237 compact241 )}
238 icon="camera"242 >
239 label="Allow camera"243 <PairingInputRow
240 tone="secondary"244 label="Address"
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}
253 keyboardType="url"245 keyboardType="url"
254 placeholder="192.168.1.100:8080"246 placeholder="192.168.1.100:3773"
255 value={hostInput}247 value={hostInput}
256 onChangeText={handleHostChange}248 onChangeText={handleHostChange}
257 />249 />
258250 <View className="ml-4 border-t border-border-subtle" />
259 <ConnectionFormField251 <PairingInputRow
260 label="Pairing code"252 label="Code"
261 autoCapitalize="none"253 placeholder="Pairing code"
262 autoCorrect={false}254 returnKeyType="go"
263 placeholder="abc-123-xyz"
264 value={codeInput}255 value={codeInput}
265 onChangeText={handleCodeChange}256 onChangeText={handleCodeChange}
257 onSubmitEditing={() => {
258 if (!connectDisabled) void handleSubmit();
259 }}
266 />260 />
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>
281 </View>261 </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}
283 </View>275 </View>
284 </ScrollView>276 </ScrollView>
285 </SettingsScreen>277 </SettingsScreen>
286 );278 );
287}279}
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 {...@@ -118,6 +118,10 @@ function convertToolbarChild(child: ReactNode): NativeStackHeaderItem | null {
118 }118 }
119119
120 const typeName = elementTypeName(child);120 const typeName = elementTypeName(child);
121 if (typeName === "NativeHeaderToolbarCustom" && isValidElement(child.props.children)) {
122 return { type: "custom", element: child.props.children };
123 }
124
121 if (typeName === "NativeHeaderToolbarButton") {125 if (typeName === "NativeHeaderToolbarButton") {
122 return {126 return {
123 type: "button",127 type: "button",
...@@ -263,6 +267,12 @@ function NativeHeaderToolbarMenuAction(_props: {...@@ -263,6 +267,12 @@ function NativeHeaderToolbarMenuAction(_props: {
263}267}
264NativeHeaderToolbarMenuAction.displayName = "NativeHeaderToolbarMenuAction";268NativeHeaderToolbarMenuAction.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
266function NativeHeaderToolbarLabel(_props: { readonly children?: ReactNode }) {276function NativeHeaderToolbarLabel(_props: { readonly children?: ReactNode }) {
267 return null;277 return null;
268}278}
...@@ -284,6 +294,7 @@ NativeHeaderToolbarSearchBarSlot.displayName = "NativeHeaderToolbarSearchBarSlot...@@ -284,6 +294,7 @@ NativeHeaderToolbarSearchBarSlot.displayName = "NativeHeaderToolbarSearchBarSlot
284294
285export const NativeHeaderToolbar = Object.assign(NativeHeaderToolbarRoot, {295export const NativeHeaderToolbar = Object.assign(NativeHeaderToolbarRoot, {
286 Button: NativeHeaderToolbarButton,296 Button: NativeHeaderToolbarButton,
297 Custom: NativeHeaderToolbarCustom,
287 Label: NativeHeaderToolbarLabel,298 Label: NativeHeaderToolbarLabel,
288 Menu: Object.assign(NativeHeaderToolbarMenu, {299 Menu: Object.assign(NativeHeaderToolbarMenu, {
289 Action: NativeHeaderToolbarMenuAction,300 Action: NativeHeaderToolbarMenuAction,