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
18 changes: 18 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,22 @@ module.exports = defineConfig([
'@typescript-eslint/no-require-imports': 'off',
},
},

{
files: ['src/screens/**/*.{ts,tsx}', 'src/navigators/**/*.{ts,tsx}'],
rules: {
'no-restricted-imports': [
'error',
{
patterns: [
{
group: ['@expo/ui', '@expo/ui/*'],
message:
'Screens must not import @expo/ui directly. Use an LNReader component wrapper (e.g. src/components/ExpoUI) instead.',
},
],
},
],
},
},
]);
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
},
"dependencies": {
"@expo/dom-webview": "^57.0.1",
"@expo/ui": "^57.0.8",
"@gorhom/bottom-sheet": "^5.2.14",
"@legendapp/list": "^3.3.3",
"@noble/ciphers": "^2.2.0",
Expand Down
548 changes: 508 additions & 40 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

25 changes: 25 additions & 0 deletions src/components/ExpoUI/ExpoHost.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { Host, type HostProps } from '@expo/ui/jetpack-compose';
import { ThemeColors } from '@theme/types';
import { getExpoHostThemeProps } from './theme';

export interface ExpoHostProps
extends Omit<HostProps, 'colorScheme' | 'seedColor'> {
/** LNReader theme used to derive the Host's colorScheme and seedColor. */
theme: ThemeColors;
}

/**
* LNReader's reusable entry point into a Jetpack Compose subtree. Screens and
* other components should render Compose primitives through this wrapper (or
* one built on it) instead of importing `@expo/ui` directly, so the
* colorScheme/seedColor mapping stays centralized in one place.
*/
export function ExpoHost({ theme, children, ...rest }: ExpoHostProps) {
const { colorScheme, seedColor } = getExpoHostThemeProps(theme);

return (
<Host colorScheme={colorScheme} seedColor={seedColor} {...rest}>
{children}
</Host>
);
}
77 changes: 77 additions & 0 deletions src/components/ExpoUI/__tests__/theme.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { getExpoHostThemeProps, getSegmentedButtonColors } from '../theme';
import type { ThemeColors } from '@theme/types';

const baseTheme = {
isDark: false,
primary: 'rgb(0, 87, 206)',
secondaryContainer: 'rgb(220, 226, 249)',
onSecondaryContainer: 'rgb(21, 27, 44)',
onSurface: 'rgb(27, 27, 31)',
outline: 'rgb(117, 119, 128)',
surfaceDisabled: 'rgba(27, 27, 31, 0.12)',
onSurfaceDisabled: 'rgba(27, 27, 31, 0.38)',
} as ThemeColors;

describe('getExpoHostThemeProps', () => {
it('maps a light theme to a light colorScheme and its primary as seedColor', () => {
expect(getExpoHostThemeProps(baseTheme)).toEqual({
colorScheme: 'light',
seedColor: baseTheme.primary,
});
});

it('maps a dark theme to a dark colorScheme', () => {
expect(
getExpoHostThemeProps({ ...baseTheme, isDark: true } as ThemeColors),
).toEqual({
colorScheme: 'dark',
seedColor: baseTheme.primary,
});
});

it('reflects custom theme accent colors so non-default themes stay in seed', () => {
const customTheme = {
...baseTheme,
primary: 'rgb(250, 128, 114)',
} as ThemeColors;

expect(getExpoHostThemeProps(customTheme).seedColor).toBe(
'rgb(250, 128, 114)',
);
});
});

describe('getSegmentedButtonColors', () => {
it('maps the selected segment to secondaryContainer/onSecondaryContainer', () => {
const colors = getSegmentedButtonColors(baseTheme);

expect(colors.activeContainerColor).toBe(baseTheme.secondaryContainer);
expect(colors.activeContentColor).toBe(baseTheme.onSecondaryContainer);
});

it('maps the unselected segment to a transparent container and onSurface text', () => {
const colors = getSegmentedButtonColors(baseTheme);

expect(colors.inactiveContainerColor).toBe('transparent');
expect(colors.inactiveContentColor).toBe(baseTheme.onSurface);
});

it('uses the theme outline color for borders in every state', () => {
const colors = getSegmentedButtonColors(baseTheme);

expect(colors.activeBorderColor).toBe(baseTheme.outline);
expect(colors.inactiveBorderColor).toBe(baseTheme.outline);
expect(colors.disabledActiveBorderColor).toBe(baseTheme.outline);
expect(colors.disabledInactiveBorderColor).toBe(baseTheme.outline);
});

it('maps disabled states to surfaceDisabled/onSurfaceDisabled', () => {
const colors = getSegmentedButtonColors(baseTheme);

expect(colors.disabledActiveContainerColor).toBe(baseTheme.surfaceDisabled);
expect(colors.disabledActiveContentColor).toBe(baseTheme.onSurfaceDisabled);
expect(colors.disabledInactiveContentColor).toBe(
baseTheme.onSurfaceDisabled,
);
});
});
4 changes: 4 additions & 0 deletions src/components/ExpoUI/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export { ExpoHost } from './ExpoHost';
export type { ExpoHostProps } from './ExpoHost';
export { getExpoHostThemeProps, getSegmentedButtonColors } from './theme';
export type { ExpoHostThemeProps } from './theme';
47 changes: 47 additions & 0 deletions src/components/ExpoUI/theme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { type ColorSchemeName, type ColorValue } from 'react-native';
import type { SegmentedButtonColors } from '@expo/ui/jetpack-compose';
import { ThemeColors } from '@theme/types';

export interface ExpoHostThemeProps {
colorScheme: ColorSchemeName;
seedColor: ColorValue;
}

/**
* Maps an LNReader theme to the Host props that seed Compose's Material You
* palette. `seedColor` keeps the generated palette in the theme's hue even on
* pre-Android-12 devices where dynamic color isn't otherwise available.
* Components that need exact parity with LNReader's custom themes (e.g.
* catppuccin, tako) should still pass explicit colors rather than relying on
* the seed-generated palette alone.
*/
export function getExpoHostThemeProps(theme: ThemeColors): ExpoHostThemeProps {
return {
colorScheme: theme.isDark ? 'dark' : 'light',
seedColor: theme.primary,
};
}

/**
* Maps LNReader's segmented-control color roles onto Compose's
* `SegmentedButtonColors`, mirroring the previous RN implementation
* (`secondaryContainer`/`onSecondaryContainer` for the selected segment).
*/
export function getSegmentedButtonColors(
theme: ThemeColors,
): SegmentedButtonColors {
return {
activeContainerColor: theme.secondaryContainer,
activeContentColor: theme.onSecondaryContainer,
activeBorderColor: theme.outline,
inactiveContainerColor: 'transparent',
inactiveContentColor: theme.onSurface,
inactiveBorderColor: theme.outline,
disabledActiveContainerColor: theme.surfaceDisabled,
disabledActiveContentColor: theme.onSurfaceDisabled,
disabledActiveBorderColor: theme.outline,
disabledInactiveContainerColor: 'transparent',
disabledInactiveContentColor: theme.onSurfaceDisabled,
disabledInactiveBorderColor: theme.outline,
};
}
43 changes: 42 additions & 1 deletion src/components/SegmentedControl/SegmentedControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ import {
GestureResponderEvent,
} from 'react-native';
import MaterialCommunityIcons from '@react-native-vector-icons/material-design-icons';
import {
SegmentedButton,
SingleChoiceSegmentedButtonRow,
} from '@expo/ui/jetpack-compose';
import { ExpoHost, getSegmentedButtonColors } from '@components/ExpoUI';
import { ThemeColors } from '@theme/types';

export interface SegmentedControlOption<T extends string = string> {
Expand All @@ -18,7 +23,7 @@ export interface SegmentedControlOption<T extends string = string> {
export interface SegmentedControlProps<T extends string = string> {
options: SegmentedControlOption<T>[];
value: T;
onChange: (value: T, event: GestureResponderEvent) => void;
onChange: (value: T, event?: GestureResponderEvent) => void;
theme: ThemeColors;
showCheckIcon?: boolean;
showLabels?: boolean;
Expand All @@ -32,6 +37,39 @@ export function SegmentedControl<T extends string = string>({
showCheckIcon = true,
showLabels = true,
}: SegmentedControlProps<T>) {
/**
* Compose's SegmentedButton only exposes a `Label` slot and always shows
* Material 3's default check icon on the selected segment, so icon-only
* controls (and the unsupported labels-without-check combination) keep the
* previous React Native implementation below.
*/
const useComposeSegmentedButtons =
showLabels && showCheckIcon && !options.some(option => option.icon);

if (useComposeSegmentedButtons) {
const colors = getSegmentedButtonColors(theme);
return (
<ExpoHost
theme={theme}
style={styles.host}
matchContents={{ vertical: true }}
>
<SingleChoiceSegmentedButtonRow>
{options.map(option => (
<SegmentedButton
key={option.value}
selected={value === option.value}
onClick={() => onChange(option.value)}
colors={colors}
>
<SegmentedButton.Label>{option.label}</SegmentedButton.Label>
</SegmentedButton>
))}
</SingleChoiceSegmentedButtonRow>
</ExpoHost>
);
}

return (
<View style={styles.container}>
{options.map((option, index) => {
Expand Down Expand Up @@ -99,6 +137,9 @@ export function SegmentedControl<T extends string = string>({
}

const styles = StyleSheet.create({
host: {
width: '100%',
},
container: {
flexDirection: 'row',
height: 40,
Expand Down
140 changes: 140 additions & 0 deletions src/components/SegmentedControl/__tests__/SegmentedControl.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { fireEvent, render, screen } from '@testing-library/react-native';

import { SegmentedControl } from '../SegmentedControl';
import type { ThemeColors } from '@theme/types';

const mockTheme = {
isDark: false,
primary: 'rgb(0, 87, 206)',
secondaryContainer: 'rgb(220, 226, 249)',
onSecondaryContainer: 'rgb(21, 27, 44)',
onSurface: 'rgb(27, 27, 31)',
outline: 'rgb(117, 119, 128)',
surfaceDisabled: 'rgba(27, 27, 31, 0.12)',
onSurfaceDisabled: 'rgba(27, 27, 31, 0.38)',
rippleColor: 'rgba(0, 87, 206, 0.12)',
} as ThemeColors;

const options = [
{ value: 'system', label: 'System' },
{ value: 'light', label: 'Light' },
{ value: 'dark', label: 'Dark' },
];

describe('SegmentedControl (label-based, Compose path)', () => {
it('renders every option as a radio-role segment', () => {
render(
<SegmentedControl
options={options}
value="system"
onChange={() => {}}
theme={mockTheme}
/>,
);

const segments = screen.getAllByRole('radio');
expect(segments).toHaveLength(3);
});

it('marks only the selected option as checked', () => {
render(
<SegmentedControl
options={options}
value="light"
onChange={() => {}}
theme={mockTheme}
/>,
);

const segments = screen.getAllByRole('radio');
expect(segments[0].props.accessibilityState.checked).toBe(false);
expect(segments[1].props.accessibilityState.checked).toBe(true);
expect(segments[2].props.accessibilityState.checked).toBe(false);
});

it('calls onChange with the pressed option value', () => {
const onChange = jest.fn();
render(
<SegmentedControl
options={options}
value="system"
onChange={onChange}
theme={mockTheme}
/>,
);

fireEvent.press(screen.getAllByRole('radio')[2]);

expect(onChange).toHaveBeenCalledWith('dark');
});

it('maps the selected segment to the theme secondaryContainer colors', () => {
render(
<SegmentedControl
options={options}
value="light"
onChange={() => {}}
theme={mockTheme}
/>,
);

const selected = screen.getAllByRole('radio')[1];
expect(selected.props.colors).toMatchObject({
activeContainerColor: mockTheme.secondaryContainer,
activeContentColor: mockTheme.onSecondaryContainer,
});
});
});

describe('SegmentedControl (icon-only, React Native fallback)', () => {
const iconOptions = [
{ value: 'left', label: 'Align left', icon: 'format-align-left' as const },
{
value: 'center',
label: 'Align center',
icon: 'format-align-center' as const,
},
];

it('keeps the Pressable-based implementation for icon-only controls', () => {
render(
<SegmentedControl
options={iconOptions}
value="left"
onChange={() => {}}
showCheckIcon={false}
showLabels={false}
theme={mockTheme}
/>,
);

// The RN fallback renders labels as an accessibilityLabel, not visible text.
expect(screen.queryByText('Align left')).toBeNull();
const segment = screen.getByLabelText('Align left');
expect(segment.props.accessibilityRole).toBe('radio');
expect(segment.props.accessibilityState).toEqual({ checked: true });
});

it('still calls onChange with the option value and press event', () => {
const onChange = jest.fn();
render(
<SegmentedControl
options={iconOptions}
value="left"
onChange={onChange}
showCheckIcon={false}
showLabels={false}
theme={mockTheme}
/>,
);

fireEvent.press(screen.getByLabelText('Align center'), {
nativeEvent: {},
});

expect(onChange).toHaveBeenCalledWith(
'center',
expect.objectContaining({ nativeEvent: expect.anything() }),
);
});
});
Loading
Loading