Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/accessibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Switchify Remote is designed for VoiceOver, TalkBack, iOS Switch Control, and An
- First-run setup explains the Remote before asking for Bluetooth. Its two steps expose headings and "Step 1 of 2"/"Step 2 of 2" announcements in logical reading order, remain scrollable at large text sizes, and never move focus to a permission prompt until Allow Bluetooth is selected.
- Surface selection is one button that announces its current value and opens a modal option list. Options expose selected state, scanning stays inside the modal, and focus returns to the selector after selection or dismissal. Toggles expose selected or disabled state.
- On Remote screens that expose controls, the Surface selector stays at the top of the scroll area after the title scrolls away. Its backdrop never becomes a scanning stop, and pinning does not move focus or trigger an announcement.
- Connected Remote control screens add one "Scroll to top" scan stop after the Surface selector pins. The 48-point control stays above the PC switcher, announces "Top of Remote" once after use, and does not animate when Reduce Motion is enabled.
- Remote keeps a quick PC switcher immediately above the tab bar in every connection state. It announces the active connection, exposes selected state for the current saved PC, and restores focus after its modal closes.
- Headings, connection changes, failures, repeat state, pairing approval, and pointer-profile recovery transitions are announced once without moving focus unexpectedly.
- The pairing code is announced one digit at a time.
Expand All @@ -25,4 +26,4 @@ Switchify Remote is designed for VoiceOver, TalkBack, iOS Switch Control, and An

## Manual smoke test

On both platforms, clear the app data before starting. Enable the screen reader and then the platform switch-access feature. Traverse both setup steps and confirm the headings, step positions, explanations, desktop download, Continue, Back, Not now, and Allow Bluetooth are announced in logical order. Confirm no Bluetooth prompt appears before Allow Bluetooth, and that the layout remains usable in both orientations and at 200% text. Then navigate PCs, pairing, every available remote screen, Settings, and Diagnostics in logical order. Confirm every action can be selected, read-only text does not become a switch-scan stop, pointer repeat exposes Stop movement, held modifiers and drag expose state, disabled controls are announced, and backgrounding returns the desktop to a neutral input state. On each Remote control screen, scroll past the title and confirm the Surface selector stays visible, gains its backdrop only while pinned, remains one scan stop, and causes no focus move or announcement. On Android, confirm Forwarding keeps its profile choices available before Start but adds mapped external switches to scanning only while forwarding is active. The mapped switches and overflow notice must disappear after manual or safety stops. In Settings, edit the Remote name and confirm validation and save status are announced without adding read-only scan stops. Confirm Use device model restores the model-based name. Confirm Privacy policy is one scan stop, announces that it opens in a browser, opens the Remote-specific policy, and returns safely if the browser cannot open. During a delayed pointer-profile response, confirm "Restoring controls" is announced once, followed by either restored controls or "Controls unavailable" after all retries. Open the Surface selector while it is pinned and confirm the underlying screen and bottom tabs are not scannable, options scan linearly, Close works, Android back and VoiceOver escape dismiss the modal, and focus returns to the selector after selection or dismissal. Repeat those checks with the Remote PC switcher, confirm it stays above the tabs while content scrolls, and switch between two saved PCs. Press Unpair for a saved computer and confirm the native alert reads the full computer name. Verify Cancel, Android Back, and outside dismissal preserve the pairing, then confirm Unpair removes it. Repeat in light and dark appearance, at 200% text scaling, with reduced motion enabled, and with iOS Reduce Transparency enabled.
On both platforms, clear the app data before starting. Enable the screen reader and then the platform switch-access feature. Traverse both setup steps and confirm the headings, step positions, explanations, desktop download, Continue, Back, Not now, and Allow Bluetooth are announced in logical order. Confirm no Bluetooth prompt appears before Allow Bluetooth, and that the layout remains usable in both orientations and at 200% text. Then navigate PCs, pairing, every available remote screen, Settings, and Diagnostics in logical order. Confirm every action can be selected, read-only text does not become a switch-scan stop, pointer repeat exposes Stop movement, held modifiers and drag expose state, disabled controls are announced, and backgrounding returns the desktop to a neutral input state. On each connected Remote control screen, scroll past the title and confirm the Surface selector stays visible, gains its backdrop only while pinned, remains one scan stop, and causes no focus move or announcement. Confirm Scroll to top then appears as one 48-point scan stop above the PC switcher, does not cover the final control, returns to the title, announces completion once, and disappears at the top. Interrupt the return gesture and confirm it remains usable without a stale announcement. Repeat with Reduce Motion and confirm the return is immediate. Confirm disconnected, recovering, and unavailable states do not expose Scroll to top. On Android, confirm Forwarding keeps its profile choices available before Start but adds mapped external switches to scanning only while forwarding is active. The mapped switches and overflow notice must disappear after manual or safety stops. In Settings, edit the Remote name and confirm validation and save status are announced without adding read-only scan stops. Confirm Use device model restores the model-based name. Confirm Privacy policy is one scan stop, announces that it opens in a browser, opens the Remote-specific policy, and returns safely if the browser cannot open. During a delayed pointer-profile response, confirm "Restoring controls" is announced once, followed by either restored controls or "Controls unavailable" after all retries. Open the Surface selector while it is pinned and confirm the underlying screen and bottom tabs are not scannable, options scan linearly, Close works, Android back and VoiceOver escape dismiss the modal, and focus returns to the selector after selection or dismissal. Repeat those checks with the Remote PC switcher, confirm it stays above the tabs while content scrolls, and switch between two saved PCs. Press Unpair for a saved computer and confirm the native alert reads the full computer name. Verify Cancel, Android Back, and outside dismissal preserve the pairing, then confirm Unpair removes it. Repeat in light and dark appearance, at 200% text scaling, with reduced motion enabled, and with iOS Reduce Transparency enabled.
1 change: 1 addition & 0 deletions docs/physical-smoke-test.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ Run the matrix on a physical Android phone and iPhone against current Switchify
- Rename a paired PC to a long fixture name. Confirm the Remote status, PC card, and Unpair control wrap without truncation.
- Open each Surface, Opening surface, and Hold to stop selector. Confirm only the selector is a scan stop while closed; while open, only the modal options and Close are scanned. Confirm selection, Close, Android back, scrim dismissal, and VoiceOver escape restore focus to the selector.
- Scroll Mouse, Typing, Window, and Android Forwarding past the Remote title. Confirm the Surface selector pins below the top safe area, the controls blur only while passing behind it, and the fixed PC switcher remains above the tab bar. Confirm pinning adds no scan stop, focus move, or announcement.
- On each connected control screen, confirm Scroll to top appears only after the selector pins, remains above the PC switcher at phone and tablet widths, and leaves the last control unobscured. Activate it twice quickly and confirm only one return occurs. Confirm it announces "Top of Remote" once, disappears at the top, cancels cleanly when the user interrupts the scroll, and jumps without animation under Reduce Motion. Confirm it is absent from disconnected, recovering, and unavailable states.
- On Android 12 or later, confirm the pinned backdrop visibly blurs the moving controls without slowing pointer repeat, typing, or Forwarding. On Android 11, confirm the themed fallback remains legible and responsive. On iOS, enable Reduce Transparency and confirm the pinned backdrop becomes opaque without changing its size or focus order.
- Repeat on a tablet at normal and maximum text size. Confirm two-column and two-pane layouts collapse when enlarged text needs the width.
9. While repeat, drag, modifiers, and a text stream are active, background and terminate the app, disconnect Bluetooth, and quit Switchify PC. Confirm the desktop returns to neutral input state each time.
Expand Down
2 changes: 1 addition & 1 deletion src/app/(tabs)/remote.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export default function RemoteScreen() {
return <Screen title="Remote" bottomAccessory={deviceSwitcher}><EmptyState icon={unavailablePresentation.icon} title={unavailablePresentation.title} body={unavailablePresentation.body} /></Screen>;
}
return (
<Screen title="Remote" headerAccessory={<StatusBadge icon="check-circle" label={`Connected · ${connection.desktop.displayName}`} tone="success" />} bottomAccessory={deviceSwitcher} stickyAccessory={<SurfaceSelector selected={preferences.surface} />}>
<Screen title="Remote" headerAccessory={<StatusBadge icon="check-circle" label={`Connected · ${connection.desktop.displayName}`} tone="success" />} bottomAccessory={deviceSwitcher} scrollToTop stickyAccessory={<SurfaceSelector selected={preferences.surface} />}>
{preferences.surface === 'mouse' ? <MouseSurface session={session} state={sessionState} physicalSwitchStopAvailable={bridgeSnapshot.captureAvailable && bridgeSnapshot.externalSwitches.length > 0} /> : null}
{preferences.surface === 'typing' ? <TypingSurface session={session} mode={preferences.typingMode} draft={preferences.draft} /> : null}
{preferences.surface === 'window' ? <WindowSurface session={session} state={sessionState} platform={connection.desktop.platform} /> : null}
Expand Down
51 changes: 45 additions & 6 deletions src/components/Screen.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { BlurTargetView } from 'expo-blur';
import { type PropsWithChildren, type ReactNode, useCallback, useRef, useState } from 'react';
import { ScrollView, View, type NativeScrollEvent, type NativeSyntheticEvent } from 'react-native';
import { AccessibilityInfo, ScrollView, View, type NativeScrollEvent, type NativeSyntheticEvent } from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';

import { AppText } from './AppText';
import { ScrollToTopButton } from './ScrollToTopButton';
import { StickyBackdrop } from './StickyBackdrop';
import { useLayout, useTheme } from '@/theme/ThemeContext';

Expand All @@ -13,6 +14,7 @@ type ScreenProps = PropsWithChildren<{
headerAccessory?: ReactNode;
bottomAccessory?: ReactNode;
nativeHeader?: boolean;
scrollToTop?: boolean;
stickyAccessory?: ReactNode;
}>;

Expand All @@ -34,17 +36,21 @@ type StickyScreenContentProps = PropsWithChildren<{
nativeHeader: boolean;
paddingBottom: number;
paddingHorizontal: number;
scrollToTop: boolean;
stackHeader: boolean;
stickyAccessory: ReactNode;
title: string;
}>;

function StickyScreenContent({ children, description, headerAccessory, isExpanded, nativeHeader, paddingBottom, paddingHorizontal, stackHeader, stickyAccessory, title }: StickyScreenContentProps) {
const { spacing } = useTheme();
function StickyScreenContent({ children, description, headerAccessory, isExpanded, nativeHeader, paddingBottom, paddingHorizontal, scrollToTop, stackHeader, stickyAccessory, title }: StickyScreenContentProps) {
const { reducedMotion, spacing } = useTheme();
const [pinned, setPinned] = useState(false);
const [scrollingToTop, setScrollingToTop] = useState(false);
const blurTarget = useRef<View>(null);
const pinnedRef = useRef(false);
const scrollRef = useRef<ScrollView>(null);
const scrollOffsetRef = useRef(0);
const scrollToTopPendingRef = useRef(false);
const stickyTopRef = useRef<number | null>(null);
const maxWidth = isExpanded ? 960 : 640;

Expand All @@ -59,12 +65,33 @@ function StickyScreenContent({ children, description, headerAccessory, isExpande
const handleScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
scrollOffsetRef.current = event.nativeEvent.contentOffset.y;
updatePinned();
if (scrollToTopPendingRef.current && scrollOffsetRef.current <= 1) {
scrollToTopPendingRef.current = false;
setScrollingToTop(false);
AccessibilityInfo.announceForAccessibilityWithOptions('Top of Remote', { queue: true });
}
}, [updatePinned]);

return <ScrollView
const cancelScrollToTop = useCallback(() => {
if (!scrollToTopPendingRef.current) return;
scrollToTopPendingRef.current = false;
setScrollingToTop(false);
}, []);

const handleScrollToTop = useCallback(() => {
if (scrollToTopPendingRef.current || scrollRef.current === null) return;
scrollToTopPendingRef.current = true;
setScrollingToTop(true);
scrollRef.current.scrollTo({ animated: !reducedMotion, y: 0 });
}, [reducedMotion]);

const scrollView = <ScrollView
ref={scrollRef}
testID="screen-scroll"
contentContainerStyle={{ alignItems: 'center', flexGrow: 1, gap: spacing.md, paddingBottom, paddingHorizontal }}
contentContainerStyle={{ alignItems: 'center', flexGrow: 1, gap: spacing.md, paddingBottom: paddingBottom + (scrollToTop ? 48 + spacing.md : 0), paddingHorizontal }}
onScroll={handleScroll}
onScrollBeginDrag={scrollToTop ? cancelScrollToTop : undefined}
onTouchStart={scrollToTop ? cancelScrollToTop : undefined}
scrollEventThrottle={16}
stickyHeaderIndices={[1]}
>
Expand All @@ -86,9 +113,20 @@ function StickyScreenContent({ children, description, headerAccessory, isExpande
{children}
</BlurTargetView>
</ScrollView>;

if (!scrollToTop) return scrollView;

return <View style={{ flex: 1 }} testID="screen-scroll-to-top-container">
{scrollView}
{pinned ? <View accessible={false} pointerEvents="box-none" style={{ alignItems: 'center', bottom: 0, left: 0, position: 'absolute', right: 0 }} testID="screen-scroll-to-top-overlay">
<View accessible={false} pointerEvents="box-none" style={{ alignItems: 'flex-end', maxWidth, paddingBottom: spacing.md, paddingHorizontal, width: '100%' }}>
<ScrollToTopButton disabled={scrollingToTop} onPress={handleScrollToTop} />
</View>
</View> : null}
</View>;
}

export function Screen({ title, description, headerAccessory, bottomAccessory, nativeHeader = false, stickyAccessory, children }: ScreenProps) {
export function Screen({ title, description, headerAccessory, bottomAccessory, nativeHeader = false, scrollToTop = false, stickyAccessory, children }: ScreenProps) {
const { colors, spacing } = useTheme();
const { isCompact, isExpanded, isLargeText } = useLayout();
const insets = useSafeAreaInsets();
Expand All @@ -109,6 +147,7 @@ export function Screen({ title, description, headerAccessory, bottomAccessory, n
nativeHeader={nativeHeader}
paddingBottom={paddingBottom}
paddingHorizontal={paddingHorizontal}
scrollToTop={scrollToTop}
stackHeader={stackHeader}
stickyAccessory={stickyAccessory}
title={title}
Expand Down
Loading