authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-04 00:48:28-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-04 00:48:28-07:00
logd60a71ef6e371d8b345ecb1f7575117d675187c7
treef916686bb8d122bd988061d77d29e480d9281760
parent1302ccacbd6d3e957f2f95239556adb6eae79a48
signature Signed by PGP key B5690EEEBB952194

feat(mobile): full-screen simulator viewer with on-demand controls (#15551)

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

7 files changed, 224 insertions(+), 80 deletions(-)

apps/mobile/app.config.ts+5
......@@ -306,6 +306,8 @@ const config: ExpoConfig = {
306306 // JS back handling survives it via react-native's Android 16 shim plus
307307 // withAndroidPredictiveBackCompat on Android 13-15.
308308 predictiveBackGestureEnabled: true,
309 // expo-sensors declares this for its pedometer, which the app does not use.
310 blockedPermissions: ["android.permission.ACTIVITY_RECOGNITION"],
309311 },
310312 web: {
311313 favicon: variant.assets.appIcon,
......@@ -440,6 +442,9 @@ const config: ExpoConfig = {
440442 },
441443 ],
442444 "./plugins/withIosCocoaPodsUuidCache.cjs",
445 // Only the accelerometer is used (device viewer shake). Compile out the
446 // pedometer so iOS needs no motion purpose string.
447 ["expo-sensors", { motionPermission: false }],
443448 // Must be listed BEFORE expo-widgets: same-type mods run last-registered-
444449 // first, so registering earlier makes this plugin's mods run AFTER
445450 // expo-widgets' — its dangerous mod wipes ios/ExpoWidgetsTarget/ (which
apps/mobile/package.json+1
......@@ -101,6 +101,7 @@
101101 "expo-paste-input": "^0.1.15",
102102 "expo-quick-actions": "^6.0.2",
103103 "expo-secure-store": "~58.0.2",
104 "expo-sensors": "~58.0.1",
104105 "expo-sharing": "58.0.13",
105106 "expo-splash-screen": "~58.0.4",
106107 "expo-sqlite": "~58.0.8",
apps/mobile/src/Stack.tsx+2
......@@ -701,6 +701,8 @@ const RootStackConfig = createNativeStackNavigator({
701701 presentation: "fullScreenModal",
702702 headerShown: false,
703703 gestureEnabled: false,
704 autoHideHomeIndicator: true,
705 navigationBarHidden: true,
704706 },
705707 }),
706708 ThreadReview: createNativeStackScreen({
apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx+145-80
......@@ -4,16 +4,32 @@ import {
44 deviceToolUpdatePolicy,
55} from "@t3tools/client-runtime/state/device";
66import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native";
7import { createNativeStackNavigator } from "@react-navigation/native-stack";
87import { EnvironmentId, ThreadId } from "@t3tools/contracts";
98import * as Cause from "effect/Cause";
109import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
11import { ActivityIndicator, Alert, AppState, Platform, Pressable, View } from "react-native";
10import * as Haptics from "expo-haptics";
11import { Accelerometer } from "expo-sensors";
12import {
13 ActivityIndicator,
14 Alert,
15 AppState,
16 BackHandler,
17 Pressable,
18 StatusBar,
19 View,
20} from "react-native";
21import Animated, { FadeIn, FadeOut, ReduceMotion } from "react-native-reanimated";
1222import { useSafeAreaInsets } from "react-native-safe-area-context";
1323
24import { SymbolView } from "../../components/AppSymbol";
1425import { AppText } from "../../components/AppText";
15import { ScreenHeader, type ScreenHeaderMenuItem } from "../../components/ScreenHeader";
16import { NativeHeaderToolbar } from "../../native/StackHeader";
26import { ControlPill, ControlPillMenu } from "../../components/ControlPill";
27import { GlassSurface } from "../../components/GlassSurface";
28import {
29 androidHeaderMenuActions,
30 findHeaderMenuAction,
31} from "../../components/headerMenu.android";
32import type { ScreenHeaderMenuItem } from "../../components/ScreenHeader.types";
1733import { deviceEnvironment, refreshDeviceHubAccess, useDeviceHubAccess } from "../../state/device";
1834import { useEnvironmentQuery } from "../../state/query";
1935import { useAtomCommand } from "../../state/use-atom-command";
......@@ -24,40 +40,26 @@ import {
2440 threadDevicePreviews,
2541 type ThreadDevicePreview,
2642} from "./threadDevicePreviews";
43import { createShakeDetector } from "./shakeDetector";
2744
28const DevicePreviewStack = createNativeStackNavigator<{ DevicePreview: undefined }>();
45const OVERLAY_ENTERING = FadeIn.duration(160).reduceMotion(ReduceMotion.System);
46const OVERLAY_EXITING = FadeOut.duration(120).reduceMotion(ReduceMotion.System);
2947
3048type DevicePreviewRouteScreenProps = StaticScreenProps<{
3149 readonly environmentId: string;
3250 readonly threadId: string;
3351}>;
3452
35/** The nested native stack supplies the navigation bar inside the modal. */
53/** Full-screen viewer; controls live in an overlay toggled by the handle or a shake. */
3654export function DevicePreviewRouteScreen({ route }: DevicePreviewRouteScreenProps) {
3755 const navigation = useNavigation();
3856 const onClose = useCallback(() => navigation.goBack(), [navigation]);
3957 return (
40 <View collapsable={false} className="flex-1 bg-sheet">
41 <DevicePreviewStack.Navigator
42 screenOptions={{
43 headerShown: Platform.OS === "ios",
44 headerBackVisible: false,
45 headerShadowVisible: false,
46 headerTransparent: false,
47 headerTitleStyle: { fontSize: 17, fontWeight: "600" },
48 }}
49 >
50 <DevicePreviewStack.Screen name="DevicePreview">
51 {() => (
52 <DevicePreviewScreen
53 environmentId={EnvironmentId.make(route.params.environmentId)}
54 threadId={ThreadId.make(route.params.threadId)}
55 onClose={onClose}
56 />
57 )}
58 </DevicePreviewStack.Screen>
59 </DevicePreviewStack.Navigator>
60 </View>
58 <DevicePreviewScreen
59 environmentId={EnvironmentId.make(route.params.environmentId)}
60 threadId={ThreadId.make(route.params.threadId)}
61 onClose={onClose}
62 />
6163 );
6264}
6365
......@@ -78,6 +80,7 @@ function DevicePreviewScreen({
7880 const [inputConnected, setInputConnected] = useState(false);
7981 const [streamAttempt, setStreamAttempt] = useState(0);
8082 const [shuttingDown, setShuttingDown] = useState(false);
83 const [controlsVisible, setControlsVisible] = useState(true);
8184 const retryHost = useAtomCommand(deviceEnvironment.list);
8285 const shutdown = useAtomCommand(deviceEnvironment.shutdown, { reportFailure: false });
8386 const streamRef = useRef<DeviceStreamRef>(null);
......@@ -87,10 +90,11 @@ function DevicePreviewScreen({
8790 [state.data, threadId],
8891 );
8992 const preview = selectedThreadDevicePreview(previews, selectedKey);
90 const onInputConnected = useCallback(
91 async (connected: boolean) => setInputConnected(connected),
92 [],
93 );
93 const onInputConnected = useCallback(async (connected: boolean) => {
94 setInputConnected(connected);
95 // Controls greet the user while connecting, then get out of the stream's way.
96 if (connected) setControlsVisible(false);
97 }, []);
9498 useEffect(() => {
9599 const subscription = AppState.addEventListener("change", (state) =>
96100 setForeground(state !== "background"),
......@@ -100,6 +104,26 @@ function DevicePreviewScreen({
100104 useEffect(() => {
101105 if (focused && state.data !== null && previews.length === 0) onClose();
102106 }, [focused, state.data, previews.length, onClose]);
107 useEffect(() => {
108 if (!focused || !foreground) return;
109 const shaken = createShakeDetector();
110 Accelerometer.setUpdateInterval(50);
111 const subscription = Accelerometer.addListener((sample) => {
112 if (!shaken({ ...sample, timestamp: sample.timestamp * 1000 })) return;
113 void Haptics.selectionAsync();
114 setControlsVisible((visible) => !visible);
115 });
116 return () => subscription.remove();
117 }, [focused, foreground]);
118 // Android back reveals the controls first, so leaving takes a deliberate second press.
119 useEffect(() => {
120 if (controlsVisible) return;
121 const subscription = BackHandler.addEventListener("hardwareBackPress", () => {
122 setControlsVisible(true);
123 return true;
124 });
125 return () => subscription.remove();
126 }, [controlsVisible]);
103127
104128 const shutDownDevice = async () => {
105129 if (!preview || shuttingDown) return;
......@@ -217,57 +241,29 @@ function DevicePreviewScreen({
217241 onPress: () => void shutDownDevice(),
218242 },
219243 ];
220 return (
221 <View className="flex-1 bg-sheet" style={{ paddingBottom: insets.bottom }}>
222 <ScreenHeader
223 title={preview?.name ?? "Devices"}
224 sidebar={false}
225 onBack={onClose}
226 options={{ headerBackVisible: false }}
227 actions={[
228 {
229 accessibilityLabel: "Home",
230 icon: "house",
231 disabled: !inputConnected,
232 onPress: () => streamRef.current?.home(),
233 },
234 ]}
235 menus={[
244 const pressHome = () => streamRef.current?.home();
245 const menuItems: ScreenHeaderMenuItem[] = [
246 ...(previews.length > 1
247 ? [
236248 {
237 title: "Device options",
238 icon: "ellipsis",
239 items: [
240 ...(previews.length > 1
241 ? [
242 {
243 id: "devices",
244 title: "Devices",
245 inline: true,
246 items: previews.map((device) => ({
247 id: device.key,
248 title: device.name,
249 subtitle: device.description,
250 selected: device.key === preview?.key,
251 onPress: () => setSelectedKey(device.key),
252 })),
253 },
254 ]
255 : []),
256 ...controls,
257 ],
249 id: "devices",
250 title: "Devices",
251 inline: true,
252 items: previews.map((device) => ({
253 id: device.key,
254 title: device.name,
255 subtitle: device.description,
256 selected: device.key === preview?.key,
257 onPress: () => setSelectedKey(device.key),
258 })),
258259 },
259 ]}
260 />
261 {Platform.OS === "ios" ? (
262 <NativeHeaderToolbar placement="left">
263 <NativeHeaderToolbar.Button
264 icon="xmark"
265 accessibilityLabel="Close device preview"
266 onPress={onClose}
267 separateBackground
268 />
269 </NativeHeaderToolbar>
270 ) : null}
260 ]
261 : []),
262 ...controls,
263 ];
264 return (
265 <View className="flex-1" style={{ backgroundColor: themeVariables["--color-sheet-solid"] }}>
266 <StatusBar hidden animated />
271267 {preview && focused && foreground ? (
272268 <OpenDevicePreview
273269 key={`${preview.key}:${streamAttempt}`}
......@@ -296,10 +292,79 @@ function DevicePreviewScreen({
296292 ) : null}
297293 </View>
298294 )}
295 {controlsVisible ? (
296 <Animated.View
297 entering={OVERLAY_ENTERING}
298 exiting={OVERLAY_EXITING}
299 pointerEvents="box-none"
300 className="absolute inset-0"
301 >
302 {inputConnected ? (
303 <Pressable
304 accessibilityLabel="Hide device controls"
305 className="absolute inset-0 bg-black/30"
306 onPress={() => setControlsVisible(false)}
307 />
308 ) : null}
309 <View className="px-3" style={{ paddingTop: Math.max(insets.top, 12) }}>
310 <GlassSurface className="flex-row items-center gap-1 p-1">
311 <ControlPill
312 accessibilityLabel="Close device preview"
313 icon="xmark"
314 onPress={onClose}
315 />
316 <AppText numberOfLines={1} className="flex-1 text-center font-t3-medium text-base">
317 {preview?.name ?? "Devices"}
318 </AppText>
319 <ControlPill
320 accessibilityLabel="Home"
321 icon="house"
322 disabled={!inputConnected}
323 onPress={pressHome}
324 />
325 <DeviceOptionsMenu items={menuItems} />
326 </GlassSurface>
327 </View>
328 </Animated.View>
329 ) : (
330 <Pressable
331 accessibilityRole="button"
332 accessibilityLabel="Show device controls"
333 accessibilityHint="Shaking the phone also shows them"
334 hitSlop={12}
335 className="absolute h-6 w-16 items-center justify-center self-center"
336 style={{ top: Math.max(insets.top - 18, 4) }}
337 onPress={() => setControlsVisible(true)}
338 >
339 <View className="h-1.5 w-10 rounded-full bg-white/40" />
340 </Pressable>
341 )}
299342 </View>
300343 );
301344}
302345
346function DeviceOptionsMenu({ items }: { readonly items: ReadonlyArray<ScreenHeaderMenuItem> }) {
347 return (
348 <ControlPillMenu
349 actions={androidHeaderMenuActions(items)}
350 isAnchoredToRight
351 title="Device options"
352 onPressAction={({ nativeEvent }) => {
353 const action = findHeaderMenuAction(items, nativeEvent.event);
354 if (action && !action.disabled) action.onPress();
355 }}
356 >
357 <Pressable
358 accessibilityLabel="Device options"
359 accessibilityRole="button"
360 className="size-11 items-center justify-center rounded-full bg-subtle"
361 >
362 <SymbolView name="ellipsis" size={18} tintColorClassName="accent-icon" />
363 </Pressable>
364 </ControlPillMenu>
365 );
366}
367
303368function OpenDevicePreview({
304369 environmentId,
305370 preview,
apps/mobile/src/features/devices/shakeDetector.test.ts created+26
......@@ -0,0 +1,26 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { createShakeDetector } from "./shakeDetector";
4
5const resting = (timestamp: number) => ({ x: 0, y: 0, z: -1, timestamp });
6const jolt = (timestamp: number) => ({ x: 2.2, y: 0.4, z: -1, timestamp });
7
8describe("shake detector", () => {
9 it("ignores gravity and a single bump", () => {
10 const detect = createShakeDetector();
11 expect(detect(resting(0))).toBe(false);
12 expect(detect(jolt(100))).toBe(false);
13 expect(detect(resting(200))).toBe(false);
14 expect(detect(jolt(900))).toBe(false);
15 });
16
17 it("reports two jolts within the window once, then cools down", () => {
18 const detect = createShakeDetector();
19 expect(detect(jolt(0))).toBe(false);
20 expect(detect(jolt(300))).toBe(true);
21 expect(detect(jolt(400))).toBe(false);
22 expect(detect(jolt(600))).toBe(false);
23 expect(detect(jolt(1_400))).toBe(false);
24 expect(detect(jolt(1_500))).toBe(true);
25 });
26});
apps/mobile/src/features/devices/shakeDetector.ts created+30
......@@ -0,0 +1,30 @@
1export interface AccelerationSample {
2 /** Acceleration per axis in g, gravity included. */
3 readonly x: number;
4 readonly y: number;
5 readonly z: number;
6 /** Milliseconds. */
7 readonly timestamp: number;
8}
9
10/**
11 * Reports a shake after two strong jolts within a short window. A single bump,
12 * setting the phone down, or walking stays below the threshold or the count.
13 */
14export function createShakeDetector({
15 threshold = 1.8,
16 window = 600,
17 cooldown = 1_000,
18}: { threshold?: number; window?: number; cooldown?: number } = {}) {
19 let jolts: number[] = [];
20 let lastShake = Number.NEGATIVE_INFINITY;
21 return (sample: AccelerationSample) => {
22 if (sample.timestamp - lastShake < cooldown) return false;
23 if (Math.hypot(sample.x, sample.y, sample.z) < threshold) return false;
24 jolts = [...jolts.filter((time) => sample.timestamp - time <= window), sample.timestamp];
25 if (jolts.length < 2) return false;
26 jolts = [];
27 lastShake = sample.timestamp;
28 return true;
29 };
30}
pnpm-lock.yaml+15
......@@ -408,6 +408,9 @@ importers:
408408 expo-secure-store:
409409 specifier: ~58.0.2
410410 version: 58.0.2(expo@58.0.2)
411 expo-sensors:
412 specifier: ~58.0.1
413 version: 58.0.1(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))
411414 expo-sharing:
412415 specifier: 58.0.13
413416 version: 58.0.13(patch_hash=fe40d857765bcd6a77aac1fed275e5b8f9415c29b972decdce05fb96de5665e6)(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)(typescript@7.0.2)
......@@ -7842,6 +7845,12 @@ packages:
78427845 peerDependencies:
78437846 expo: '*'
78447847
7848 expo-sensors@58.0.1:
7849 resolution: {integrity: sha512-hXS10z/wu55sYv++yPstmSjaA7pTGHV1Rc3kAhThf/hZbJZXtbIaFMGsgujIH6RFqi5pcUho0aMWHjvdCewH8g==}
7850 peerDependencies:
7851 expo: '*'
7852 react-native: '*'
7853
78457854 expo-server@58.0.2:
78467855 resolution: {integrity: sha512-QOdIBRZ7hEhlKzDud99/4YPggg+rASCD901PuuDy/oHzSP+eQKs2B4PDwzwfYvtelDA45nOwJsyCKnBvwpuJZA==}
78477856 engines: {node: ^22.13.0 || ^24.3.0 || ^26.0.0 || >=27.0.0}
......@@ -18221,6 +18230,12 @@ snapshots:
1822118230 dependencies:
1822218231 expo: 58.0.2(56fc8ddb8129740412739f0f0a6ee025)
1822318232
18233 expo-sensors@58.0.1(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6)):
18234 dependencies:
18235 expo: 58.0.2(56fc8ddb8129740412739f0f0a6ee025)
18236 invariant: 2.2.4
18237 react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6)
18238
1822418239 expo-server@58.0.2: {}
1822518240
1822618241 expo-sharing@58.0.13(patch_hash=fe40d857765bcd6a77aac1fed275e5b8f9415c29b972decdce05fb96de5665e6)(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)(typescript@7.0.2):