Skip to content

Commit 7a18715

Browse files
authored
feat(predict): scaffold Crypto Up/Down detail screen with header (#28628)
## **Description** Scaffolds the new Crypto Up/Down detail screen (`PredictCryptoUpDownDetails`) with an animated header, laying the groundwork for the full experience in future tickets. **What changed:** - **New `PredictCryptoUpDownDetails` component** — Uses `HeaderStandardAnimated` + `TitleSubpage` + `Animated.ScrollView` to match the Perps header pattern. Displays the market's series title, formatted end date, market image, and a share button. - **Updated `isCryptoUpDown()`** — Now requires both `up-or-down` AND `crypto` tags, filtering out non-crypto up-or-down markets. - **Feature-flagged routing** — `PredictMarketDetails` branches to the new screen only when `predictUpDown` flag is enabled and `isCryptoUpDown(market)` is true. - **Extracted `usePredictShare` hook** — Pulled share logic (tracking, native share sheet, clipboard toast) out of `PredictShareButton` into a reusable hook for use with `endButtonIconProps`. - **Added `formatMarketEndDate`** — Intl-based date formatter using the user's local timezone. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: [PRED-786](https://consensyssoftware.atlassian.net/browse/PRED-786) ## **Manual testing steps** ```gherkin Feature: Crypto Up/Down detail screen scaffold Scenario: user navigates to a crypto up-or-down market with the flag enabled Given the predictUpDown feature flag is enabled And there is a market with series, "up-or-down" tag, and "crypto" tag When user taps on that market from the feed Then the new Crypto Up/Down detail screen renders And the header shows the series title and formatted end date And the share button is visible in the header And pull-to-refresh works on the scroll view Scenario: user navigates to a crypto up-or-down market with the flag disabled Given the predictUpDown feature flag is disabled And there is a market with series, "up-or-down" tag, and "crypto" tag When user taps on that market from the feed Then the default market details screen renders Scenario: user navigates to a non-crypto up-or-down market Given the predictUpDown feature flag is enabled And there is a market with series and "up-or-down" tag but no "crypto" tag When user taps on that market from the feed Then the default market details screen renders ``` ## **Screenshots/Recordings** ### **Before** <!-- N/A - new screen --> ### **After** <!-- Screenshots to be added --> DEMO: https://www.loom.com/share/95cf9d133e35439dac7da42ab2ea32a7 ## **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. [PRED-786]: https://consensyssoftware.atlassian.net/browse/PRED-786?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Adds a new feature-flagged navigation branch in `PredictMarketDetails` and refactors share behavior into a reusable hook, which could affect which details screen renders and how share events/toasts are triggered. > > **Overview** > Introduces a new `PredictCryptoUpDownDetails` screen scaffold with an animated header, pull-to-refresh scroll view, market image/title, and header share action, plus new test IDs and component/unit tests. > > Updates `PredictMarketDetails` to **conditionally route** crypto up/down markets to this new screen when `selectPredictUpDownEnabledFlag` is enabled and `isCryptoUpDown()` matches (now requiring both `up-or-down` and `crypto` tags). > > Refactors share handling by extracting the native share + analytics + clipboard toast logic from `PredictShareButton` into a reusable `usePredictShare` hook (with a JSX helper util), and adds `formatMarketEndDate` for localized end-date display (with tests). > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 8eaad98. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent e0238a1 commit 7a18715

14 files changed

Lines changed: 723 additions & 79 deletions

File tree

app/components/UI/Predict/Predict.testIds.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,19 @@ export const PredictMarketDetailsSelectorsIDs = {
128128
'predict-details-buttons-skeleton-button-1',
129129
} as const;
130130

131+
// ========================================
132+
// PREDICT CRYPTO UP/DOWN DETAILS SELECTORS
133+
// ========================================
134+
135+
export const PredictCryptoUpDownDetailsSelectorsIDs = {
136+
SCREEN: 'predict-crypto-up-down-details-screen',
137+
HEADER: 'predict-crypto-up-down-details-header',
138+
BACK_BUTTON: 'predict-crypto-up-down-details-back-button',
139+
SHARE_BUTTON: 'predict-crypto-up-down-details-share-button',
140+
SCROLL_VIEW: 'predict-crypto-up-down-details-scroll-view',
141+
TITLE_SECTION: 'predict-crypto-up-down-details-title-section',
142+
} as const;
143+
131144
export const PredictMarketDetailsSelectorsText = {
132145
// Tab content containers
133146
ABOUT_TAB_TEXT: 'About',
Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
import React from 'react';
2+
import { render, screen } from '@testing-library/react-native';
3+
import PredictCryptoUpDownDetails from './PredictCryptoUpDownDetails';
4+
import { PredictCryptoUpDownDetailsSelectorsIDs } from '../../Predict.testIds';
5+
import type { PredictMarket, PredictSeries } from '../../types';
6+
import usePredictShare from '../../hooks/usePredictShare';
7+
8+
const mockUsePredictShare = usePredictShare as jest.Mock;
9+
10+
jest.mock('@metamask/design-system-twrnc-preset', () => ({
11+
useTailwind: () => ({
12+
style: jest.fn(() => ({})),
13+
}),
14+
}));
15+
16+
jest.mock('react-native-reanimated', () => {
17+
const { ScrollView } = jest.requireActual('react-native');
18+
return {
19+
...jest.requireActual('react-native-reanimated'),
20+
Animated: { ScrollView },
21+
useSharedValue: jest.fn((initialValue: number) => ({
22+
value: initialValue,
23+
})),
24+
useAnimatedScrollHandler: jest.fn(() => jest.fn()),
25+
};
26+
});
27+
28+
jest.mock('react-native-safe-area-context', () => {
29+
const { View } = jest.requireActual('react-native');
30+
return {
31+
SafeAreaView: View,
32+
SafeAreaProvider: ({ children }: { children: React.ReactNode }) => children,
33+
useSafeAreaInsets: jest.fn(() => ({
34+
top: 0,
35+
right: 0,
36+
bottom: 0,
37+
left: 0,
38+
})),
39+
useSafeAreaFrame: () => ({ x: 0, y: 0, width: 375, height: 812 }),
40+
};
41+
});
42+
43+
jest.mock(
44+
'../../../../../component-library/components-temp/HeaderStandardAnimated/useHeaderStandardAnimated',
45+
() => ({
46+
__esModule: true,
47+
default: () => ({
48+
scrollY: { value: 0 },
49+
titleSectionHeightSv: { value: 0 },
50+
setTitleSectionHeight: jest.fn(),
51+
onScroll: jest.fn(),
52+
}),
53+
}),
54+
);
55+
56+
jest.mock('../../hooks/usePredictShare', () => {
57+
const mockUsePredictShareFn = jest.fn(
58+
({ marketId, marketSlug }: { marketId?: string; marketSlug?: string }) => ({
59+
handleSharePress: jest.fn(),
60+
marketId,
61+
marketSlug,
62+
}),
63+
);
64+
return {
65+
__esModule: true,
66+
default: mockUsePredictShareFn,
67+
};
68+
});
69+
70+
jest.mock('../../utils/format', () => ({
71+
formatMarketEndDate: jest.fn(() => 'April 9, 1:45 PM'),
72+
}));
73+
74+
const createMockMarket = (
75+
overrides: Partial<PredictMarket> = {},
76+
): PredictMarket & { series: PredictSeries } =>
77+
({
78+
id: 'market-1',
79+
providerId: 'polymarket',
80+
slug: 'btc-up-or-down-5m',
81+
title: 'BTC Up or Down - 5 Minutes',
82+
description: 'Will BTC go up or down?',
83+
image: 'https://example.com/btc.png',
84+
status: 'open',
85+
recurrence: 'NONE',
86+
category: 'crypto',
87+
tags: ['crypto', 'up-or-down'],
88+
outcomes: [],
89+
liquidity: 100,
90+
volume: 200,
91+
endDate: '2026-04-09T19:45:00Z',
92+
series: {
93+
id: 's1',
94+
slug: 'btc-up-or-down-5m',
95+
title: 'BTC Up or Down - 5 Minutes',
96+
recurrence: '5m',
97+
},
98+
...overrides,
99+
}) as PredictMarket & { series: PredictSeries };
100+
101+
describe('PredictCryptoUpDownDetails', () => {
102+
const mockOnBack = jest.fn();
103+
const mockOnRefresh = jest.fn();
104+
105+
beforeEach(() => {
106+
jest.clearAllMocks();
107+
});
108+
109+
it('renders the screen container with correct testID', () => {
110+
const market = createMockMarket();
111+
112+
render(
113+
<PredictCryptoUpDownDetails
114+
market={market}
115+
onBack={mockOnBack}
116+
onRefresh={mockOnRefresh}
117+
refreshing={false}
118+
/>,
119+
);
120+
121+
expect(
122+
screen.getByTestId(PredictCryptoUpDownDetailsSelectorsIDs.SCREEN),
123+
).toBeOnTheScreen();
124+
});
125+
126+
it('renders the header with the series title text', () => {
127+
const market = createMockMarket({
128+
series: {
129+
id: 's1',
130+
slug: 'btc-up-or-down-5m',
131+
title: 'BTC Up or Down - 5 Minutes',
132+
recurrence: '5m',
133+
},
134+
});
135+
136+
render(
137+
<PredictCryptoUpDownDetails
138+
market={market}
139+
onBack={mockOnBack}
140+
onRefresh={mockOnRefresh}
141+
refreshing={false}
142+
/>,
143+
);
144+
145+
expect(
146+
screen.getByTestId(PredictCryptoUpDownDetailsSelectorsIDs.HEADER),
147+
).toBeOnTheScreen();
148+
expect(
149+
screen.getAllByText('BTC Up or Down - 5 Minutes').length,
150+
).toBeGreaterThan(0);
151+
});
152+
153+
it('renders the formatted endDate as subtitle in the header', () => {
154+
const market = createMockMarket({
155+
endDate: '2026-04-09T19:45:00Z',
156+
});
157+
158+
render(
159+
<PredictCryptoUpDownDetails
160+
market={market}
161+
onBack={mockOnBack}
162+
onRefresh={mockOnRefresh}
163+
refreshing={false}
164+
/>,
165+
);
166+
167+
expect(screen.getAllByText('April 9, 1:45 PM').length).toBeGreaterThan(0);
168+
});
169+
170+
it('renders the share button in the header end area', () => {
171+
const market = createMockMarket();
172+
173+
render(
174+
<PredictCryptoUpDownDetails
175+
market={market}
176+
onBack={mockOnBack}
177+
onRefresh={mockOnRefresh}
178+
refreshing={false}
179+
/>,
180+
);
181+
182+
expect(
183+
screen.getByTestId(PredictCryptoUpDownDetailsSelectorsIDs.SHARE_BUTTON),
184+
).toBeOnTheScreen();
185+
});
186+
187+
it('calls usePredictShare with market id and slug', () => {
188+
const market = createMockMarket({
189+
id: 'market-123',
190+
slug: 'btc-up-or-down-5m',
191+
});
192+
193+
render(
194+
<PredictCryptoUpDownDetails
195+
market={market}
196+
onBack={mockOnBack}
197+
onRefresh={mockOnRefresh}
198+
refreshing={false}
199+
/>,
200+
);
201+
202+
expect(mockUsePredictShare).toHaveBeenCalledWith({
203+
marketId: 'market-123',
204+
marketSlug: 'btc-up-or-down-5m',
205+
});
206+
});
207+
208+
it('renders the title section with the series title text', () => {
209+
const market = createMockMarket({
210+
series: {
211+
id: 's1',
212+
slug: 'btc-up-or-down-5m',
213+
title: 'BTC Up or Down - 5 Minutes',
214+
recurrence: '5m',
215+
},
216+
});
217+
218+
render(
219+
<PredictCryptoUpDownDetails
220+
market={market}
221+
onBack={mockOnBack}
222+
onRefresh={mockOnRefresh}
223+
refreshing={false}
224+
/>,
225+
);
226+
227+
const titleSection = screen.getByTestId(
228+
PredictCryptoUpDownDetailsSelectorsIDs.TITLE_SECTION,
229+
);
230+
expect(titleSection).toBeOnTheScreen();
231+
expect(
232+
screen.getAllByText('BTC Up or Down - 5 Minutes').length,
233+
).toBeGreaterThan(0);
234+
});
235+
236+
it('renders the title section with formatted endDate as bottom label', () => {
237+
const market = createMockMarket({
238+
endDate: '2026-04-09T19:45:00Z',
239+
});
240+
241+
render(
242+
<PredictCryptoUpDownDetails
243+
market={market}
244+
onBack={mockOnBack}
245+
onRefresh={mockOnRefresh}
246+
refreshing={false}
247+
/>,
248+
);
249+
250+
const titleSection = screen.getByTestId(
251+
PredictCryptoUpDownDetailsSelectorsIDs.TITLE_SECTION,
252+
);
253+
expect(titleSection).toBeOnTheScreen();
254+
expect(screen.getAllByText('April 9, 1:45 PM').length).toBeGreaterThan(0);
255+
});
256+
257+
it('renders no subtitle text when endDate is undefined', () => {
258+
const market = createMockMarket({
259+
endDate: undefined,
260+
});
261+
262+
render(
263+
<PredictCryptoUpDownDetails
264+
market={market}
265+
onBack={mockOnBack}
266+
onRefresh={mockOnRefresh}
267+
refreshing={false}
268+
/>,
269+
);
270+
271+
expect(screen.queryByText('April 9, 1:45 PM')).not.toBeOnTheScreen();
272+
});
273+
});
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import React from 'react';
2+
import { Image, RefreshControl } from 'react-native';
3+
import Animated from 'react-native-reanimated';
4+
import { SafeAreaView } from 'react-native-safe-area-context';
5+
import { Box, IconName } from '@metamask/design-system-react-native';
6+
import { useTailwind } from '@metamask/design-system-twrnc-preset';
7+
import HeaderStandardAnimated from '../../../../../component-library/components-temp/HeaderStandardAnimated';
8+
import useHeaderStandardAnimated from '../../../../../component-library/components-temp/HeaderStandardAnimated/useHeaderStandardAnimated';
9+
import TitleSubpage from '../../../../../component-library/components-temp/TitleSubpage';
10+
import type { PredictMarket, PredictSeries } from '../../types';
11+
import { formatMarketEndDate } from '../../utils/format';
12+
import usePredictShare from '../../hooks/usePredictShare';
13+
import { PredictCryptoUpDownDetailsSelectorsIDs } from '../../Predict.testIds';
14+
15+
export interface PredictCryptoUpDownDetailsProps {
16+
market: PredictMarket & { series: PredictSeries };
17+
onBack: () => void;
18+
onRefresh: () => void;
19+
refreshing: boolean;
20+
}
21+
22+
const PredictCryptoUpDownDetails: React.FC<PredictCryptoUpDownDetailsProps> = ({
23+
market,
24+
onBack,
25+
onRefresh,
26+
refreshing,
27+
}) => {
28+
const tw = useTailwind();
29+
const { scrollY, titleSectionHeightSv, setTitleSectionHeight, onScroll } =
30+
useHeaderStandardAnimated();
31+
const { handleSharePress } = usePredictShare({
32+
marketId: market.id,
33+
marketSlug: market.slug,
34+
});
35+
36+
const title = market.series.title;
37+
const subtitle = market.endDate
38+
? formatMarketEndDate(market.endDate)
39+
: undefined;
40+
41+
return (
42+
<SafeAreaView
43+
style={tw.style('flex-1 bg-default')}
44+
edges={['left', 'right', 'top']}
45+
testID={PredictCryptoUpDownDetailsSelectorsIDs.SCREEN}
46+
>
47+
<HeaderStandardAnimated
48+
scrollY={scrollY}
49+
titleSectionHeight={titleSectionHeightSv}
50+
title={title}
51+
subtitle={subtitle}
52+
onBack={onBack}
53+
backButtonProps={{
54+
testID: PredictCryptoUpDownDetailsSelectorsIDs.BACK_BUTTON,
55+
}}
56+
endButtonIconProps={[
57+
{
58+
iconName: IconName.Share,
59+
onPress: handleSharePress,
60+
testID: PredictCryptoUpDownDetailsSelectorsIDs.SHARE_BUTTON,
61+
},
62+
]}
63+
testID={PredictCryptoUpDownDetailsSelectorsIDs.HEADER}
64+
/>
65+
66+
<Animated.ScrollView
67+
onScroll={onScroll}
68+
scrollEventThrottle={16}
69+
showsVerticalScrollIndicator={false}
70+
testID={PredictCryptoUpDownDetailsSelectorsIDs.SCROLL_VIEW}
71+
refreshControl={
72+
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
73+
}
74+
>
75+
<Box
76+
testID={PredictCryptoUpDownDetailsSelectorsIDs.TITLE_SECTION}
77+
onLayout={(e) => setTitleSectionHeight(e.nativeEvent.layout.height)}
78+
>
79+
<TitleSubpage
80+
startAccessory={
81+
<Box twClassName="w-10 h-10 rounded-lg bg-muted overflow-hidden">
82+
{market.image ? (
83+
<Image
84+
source={{ uri: market.image }}
85+
style={tw.style('w-full h-full')}
86+
resizeMode="cover"
87+
/>
88+
) : (
89+
<Box twClassName="w-full h-full bg-muted" />
90+
)}
91+
</Box>
92+
}
93+
title={title}
94+
bottomLabel={subtitle}
95+
twClassName="px-4 pt-1 pb-3"
96+
/>
97+
</Box>
98+
</Animated.ScrollView>
99+
</SafeAreaView>
100+
);
101+
};
102+
103+
export default PredictCryptoUpDownDetails;

0 commit comments

Comments
 (0)