Skip to content

Commit 2373d09

Browse files
feat(perps): chart improvements with volume and fullscreen view (MetaMask#22843)
## **Description** This PR implements volume histogram and fullscreen chart mode for Perps trading, addressing the lack of volume data visualization and limited screen real estate for chart analysis on mobile. ### What is the reason for the change? The current Perps chart lacks essential trading features: - No volume data visualization (critical for confirming price movements) - No fullscreen/landscape mode for detailed chart analysis - Limited screen space forces traders to use external platforms (TradingView, CEX apps) ### What is the improvement/solution? **Volume Histogram** - USD notional volume bars (volume × price) displayed below candlesticks - Smart formatting with K/M/B/T suffixes (e.g., "120M" vs "120,000,000") - Color-coded bars (green/red) matching candle direction - Clean presentation with hidden Y-axis labels and transparent separator **Fullscreen Chart Modal** - Immersive chart experience with minimized UI chrome - Auto-unlock device orientation (follows rotation in fullscreen, locks to portrait when closed) - Cross-platform safe area handling (iOS notch + Android navigation bar) - OHLCV data bar shows Open/High/Low/Close/Volume values **Key Changes** - Added `PerpsChartFullscreenModal` component with orientation management - Added `PerpsOHLCVBar` component for real-time OHLC/volume display - Enhanced `TradingViewChartTemplate` with multi-pane architecture (80% candlesticks / 20% volume) - Added `expo-screen-orientation` dependency for native orientation control - Updated Android/iOS config to support landscape orientation in fullscreen only ## **Changelog** CHANGELOG entry: Added fullscreen chart mode with landscape orientation support and volume histogram for Perps trading ## **Related issues** Relates to: Mobile Perps charting UX v2 PRD - Chart Foundations (P0) ## **Manual testing steps** ```gherkin Feature: Fullscreen chart with volume histogram Scenario: User views volume bars in market detail view Given user is on Perps market detail screen When user scrolls to chart section Then volume histogram bars are visible below candlesticks And volume values are formatted with K/M/B suffixes And volume bars use green/red colors matching candle direction Scenario: User enters fullscreen chart mode Given user is on Perps market detail screen When user taps fullscreen icon in chart header Then chart expands to fullscreen modal And header shows interval selector and close button And volume bars are always visible And device orientation unlocks to follow device rotation Scenario: User rotates device in fullscreen Given user is in fullscreen chart mode (portrait) When user rotates device to landscape Then chart automatically follows orientation change And volume bars properly rescale to new dimensions When user rotates device back to portrait Then chart returns to portrait orientation And volume bars rescale appropriately Scenario: User exits fullscreen mode Given user is in fullscreen chart mode (any orientation) When user taps Close button (X icon) Then chart exits fullscreen And device orientation locks back to portrait And user returns to market detail view Scenario: Cross-platform safe area handling Given user is on iOS device with notch When user enters fullscreen chart mode Then header content avoids notch area And chart content avoids bottom safe area ``` ## **Screenshots/Recordings** ### **Before** - No volume data visible on chart - No fullscreen/landscape mode - Limited chart analysis capability on mobile ### **After** **Volume Histogram in Market Detail View:** - Volume bars visible below candlesticks with USD notional values - Smart formatting: "120M" instead of "120,000,000" - Color-coded green (up) / red (down) **Fullscreen Modal - Portrait:** - Clean fullscreen interface with minimal chrome - Header: Interval selector (left), Close button (right) - Volume bars always visible - OHLCV data bar with proper padding - Safe area handling for iOS notch and Android status bar **Fullscreen Modal - Landscape:** - Maximum screen real estate for chart analysis - Volume bars automatically rescale after rotation - Orientation locks back to portrait when exiting https://github.com/user-attachments/assets/f9582cfa-fa5c-449b-a5de-d9750e0da05d ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Adds a fullscreen Perps chart with device rotation handling, introduces volume histogram and an OHLCV bar, upgrades the chart engine, and wires in expo-screen-orientation with config and tests. > > - **Perps UI**: > - Add `PerpsChartFullscreenModal` with orientation lock/unlock, header controls, safe area handling, and tests. > - Add `PerpsOHLCVBar` and render it in `PerpsMarketDetailsView` and fullscreen modal. > - Update `PerpsMarketHeader` to include a fullscreen button; minor styles; make interval selector horizontally scrollable. > - Enhance `PerpsMarketDetailsView`: integrate new components, skeleton fallback, auto-zoom on interval change, improved error logging. > - **Chart Engine**: > - Extend `TradingViewChart` and template: volume histogram in separate pane, OHLCV callbacks, overlay/volume toggles, pane sizing/resizing, improved time/price formatting, and ref methods; expose `OhlcData` types. > - Adjust TPSL line styling/colors and add skeleton/error handling; comprehensive unit tests added. > - **Config/Dependencies**: > - Add `expo-screen-orientation` (plugin, mock, Jest mapping); enable landscape orientations in `ios/MetaMask/Info.plist`; register in `app.config.js`; update Pods/Yarn. > - **Logging/Services**: > - Replace `console.error` with `Logger.error` in Perps flows; add error logging in `HyperLiquidSubscriptionService` unsubscriptions. > - **Selectors/i18n**: > - Add E2E selectors for OHLCV bar and fullscreen modal; add i18n strings for OHLC labels. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 5032204. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Nicholas Smith <nick.smith@consensys.net>
1 parent 7fd610b commit 2373d09

31 files changed

Lines changed: 2295 additions & 170 deletions

app.config.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ module.exports = {
3030
],
3131

3232
'expo-apple-authentication',
33+
[
34+
'expo-screen-orientation',
35+
{
36+
initialOrientation: 'PORTRAIT',
37+
},
38+
],
3339
],
3440
android: {
3541
package:
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
// mock expo-screen-orientation for testing
2+
3+
export const lockAsync = jest.fn().mockResolvedValue(undefined);
4+
export const unlockAsync = jest.fn().mockResolvedValue(undefined);
5+
export const getOrientationAsync = jest.fn().mockResolvedValue(1); // Portrait
6+
export const getOrientationLockAsync = jest.fn().mockResolvedValue(0);
7+
export const getPlatformOrientationLockAsync = jest.fn().mockResolvedValue({});
8+
export const supportsOrientationLockAsync = jest.fn().mockResolvedValue(true);
9+
10+
export const Orientation = {
11+
UNKNOWN: 0,
12+
PORTRAIT_UP: 1,
13+
PORTRAIT_DOWN: 2,
14+
LANDSCAPE_LEFT: 3,
15+
LANDSCAPE_RIGHT: 4,
16+
};
17+
18+
export const OrientationLock = {
19+
DEFAULT: 0,
20+
ALL: 1,
21+
PORTRAIT: 2,
22+
PORTRAIT_UP: 3,
23+
PORTRAIT_DOWN: 4,
24+
LANDSCAPE: 5,
25+
LANDSCAPE_LEFT: 6,
26+
LANDSCAPE_RIGHT: 7,
27+
OTHER: 8,
28+
UNKNOWN: 9,
29+
};
30+
31+
export const SizeClassIOS = {
32+
UNKNOWN: 0,
33+
COMPACT: 1,
34+
REGULAR: 2,
35+
};
36+
37+
export const WebOrientationLock = {
38+
PORTRAIT_PRIMARY: 'portrait-primary',
39+
PORTRAIT_SECONDARY: 'portrait-secondary',
40+
LANDSCAPE_PRIMARY: 'landscape-primary',
41+
LANDSCAPE_SECONDARY: 'landscape-secondary',
42+
PORTRAIT: 'portrait',
43+
LANDSCAPE: 'landscape',
44+
NATURAL: 'natural',
45+
ANY: 'any',
46+
UNKNOWN: 'unknown',
47+
};
48+
49+
export const WebOrientation = {
50+
PORTRAIT_PRIMARY: 0,
51+
PORTRAIT_SECONDARY: 180,
52+
LANDSCAPE_PRIMARY: 90,
53+
LANDSCAPE_SECONDARY: -90,
54+
};
55+
56+
// Default export for namespace imports
57+
export default {
58+
lockAsync,
59+
unlockAsync,
60+
getOrientationAsync,
61+
getOrientationLockAsync,
62+
getPlatformOrientationLockAsync,
63+
supportsOrientationLockAsync,
64+
Orientation,
65+
OrientationLock,
66+
SizeClassIOS,
67+
WebOrientationLock,
68+
WebOrientation,
69+
};

app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.styles.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export const createStyles = ({ theme }: { theme: Theme }) =>
4040
chartSection: {
4141
paddingTop: 0,
4242
marginTop: 16,
43+
position: 'relative',
4344
},
4445
tabsSection: {
4546
paddingVertical: 8,

app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx

Lines changed: 120 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,25 @@ jest.mock('react-native/Libraries/Linking/Linking', () => ({
1919
getInitialURL: jest.fn(() => Promise.resolve(null)),
2020
}));
2121

22+
jest.mock('react-native-modal', () => {
23+
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires
24+
const { View } = require('react-native');
25+
return ({
26+
isVisible,
27+
children,
28+
...props
29+
}: {
30+
isVisible: boolean;
31+
children: React.ReactNode;
32+
[key: string]: unknown;
33+
}) =>
34+
isVisible ? (
35+
<View testID="modal-container" {...props}>
36+
{children}
37+
</View>
38+
) : null;
39+
});
40+
2241
// Mock @consensys/native-ramps-sdk to provide missing enum
2342
jest.mock('@consensys/native-ramps-sdk', () => ({
2443
...jest.requireActual('@consensys/native-ramps-sdk'),
@@ -59,6 +78,20 @@ jest.mock('../../selectors/chartPreferences', () => ({
5978
selectPerpsChartPreferredCandlePeriod: jest.fn(() => '15m'),
6079
}));
6180

81+
// Mock Logger
82+
const mockLoggerError = jest.fn();
83+
const mockLoggerLog = jest.fn();
84+
const mockLoggerWarn = jest.fn();
85+
86+
jest.mock('../../../../../util/Logger', () => ({
87+
__esModule: true,
88+
default: {
89+
error: (...args: unknown[]) => mockLoggerError(...args),
90+
log: (...args: unknown[]) => mockLoggerLog(...args),
91+
warn: (...args: unknown[]) => mockLoggerWarn(...args),
92+
},
93+
}));
94+
6295
// Create mock functions that can be modified during tests
6396
const mockUsePerpsAccount = jest.fn();
6497
const mockUsePerpsLiveAccount = jest.fn();
@@ -1428,8 +1461,7 @@ describe('PerpsMarketDetailsView', () => {
14281461
});
14291462

14301463
it('handles error when opening TradingView URL fails', async () => {
1431-
// Mock console.error to avoid test output pollution
1432-
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
1464+
mockLoggerError.mockClear();
14331465

14341466
// Mock Linking.openURL to reject
14351467
(Linking.openURL as jest.Mock).mockRejectedValueOnce(
@@ -1449,15 +1481,97 @@ describe('PerpsMarketDetailsView', () => {
14491481
const tradingViewLink = getByText('TradingView.');
14501482
fireEvent.press(tradingViewLink);
14511483

1452-
// Wait for the error to be logged
1484+
// Wait for the error to be logged using Logger.error
14531485
await waitFor(() => {
1454-
expect(consoleErrorSpy).toHaveBeenCalledWith(
1455-
'Failed to open Trading View URL:',
1486+
expect(mockLoggerError).toHaveBeenCalledWith(
14561487
expect.any(Error),
1488+
expect.objectContaining({
1489+
feature: 'perps',
1490+
message: 'Failed to open Trading View URL',
1491+
}),
14571492
);
14581493
});
1494+
});
1495+
});
14591496

1460-
consoleErrorSpy.mockRestore();
1497+
describe('Fullscreen chart functionality', () => {
1498+
it('opens fullscreen chart modal when fullscreen button is pressed', async () => {
1499+
const { getByTestId, queryByTestId } = renderWithProvider(
1500+
<PerpsConnectionProvider>
1501+
<PerpsMarketDetailsView />
1502+
</PerpsConnectionProvider>,
1503+
{
1504+
state: initialState,
1505+
},
1506+
);
1507+
1508+
// Verify close button is not initially visible (modal is closed)
1509+
expect(queryByTestId('perps-chart-fullscreen-close-button')).toBeNull();
1510+
1511+
// Press fullscreen button
1512+
const fullscreenButton = getByTestId(
1513+
'perps-market-header-fullscreen-button',
1514+
);
1515+
fireEvent.press(fullscreenButton);
1516+
1517+
// Verify modal is now visible by checking for close button
1518+
await waitFor(() => {
1519+
expect(getByTestId('perps-chart-fullscreen-close-button')).toBeTruthy();
1520+
});
1521+
});
1522+
1523+
it('closes fullscreen chart modal when close button is pressed', async () => {
1524+
const { getByTestId, queryByTestId } = renderWithProvider(
1525+
<PerpsConnectionProvider>
1526+
<PerpsMarketDetailsView />
1527+
</PerpsConnectionProvider>,
1528+
{
1529+
state: initialState,
1530+
},
1531+
);
1532+
1533+
// Open the modal first
1534+
const fullscreenButton = getByTestId(
1535+
'perps-market-header-fullscreen-button',
1536+
);
1537+
fireEvent.press(fullscreenButton);
1538+
1539+
// Wait for modal to be visible
1540+
await waitFor(() => {
1541+
expect(getByTestId('perps-chart-fullscreen-close-button')).toBeTruthy();
1542+
});
1543+
1544+
// Press close button
1545+
const closeButton = getByTestId('perps-chart-fullscreen-close-button');
1546+
fireEvent.press(closeButton);
1547+
1548+
// Verify modal is closed
1549+
await waitFor(() => {
1550+
expect(queryByTestId('perps-chart-fullscreen-close-button')).toBeNull();
1551+
});
1552+
});
1553+
1554+
it('renders fullscreen chart when modal is open', async () => {
1555+
const { getByTestId } = renderWithProvider(
1556+
<PerpsConnectionProvider>
1557+
<PerpsMarketDetailsView />
1558+
</PerpsConnectionProvider>,
1559+
{
1560+
state: initialState,
1561+
},
1562+
);
1563+
1564+
// Open the modal
1565+
const fullscreenButton = getByTestId(
1566+
'perps-market-header-fullscreen-button',
1567+
);
1568+
fireEvent.press(fullscreenButton);
1569+
1570+
// Wait for modal to be visible and verify chart is rendered
1571+
await waitFor(() => {
1572+
expect(getByTestId('perps-chart-fullscreen-close-button')).toBeTruthy();
1573+
expect(getByTestId('fullscreen-chart')).toBeTruthy();
1574+
});
14611575
});
14621576
});
14631577

0 commit comments

Comments
 (0)