Skip to content

Commit 58ca663

Browse files
feat: batch sell loading and stablecoin selection (#29879)
<!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until this PR meets the canonical Definition of Ready For Review in `docs/readme/ready-for-review.md`. In short: the template must be materially complete (not just section titles present), all status checks must be currently passing, and the only expected follow-up commits must be reviewer-driven. --> ## **Description** <!-- Write a short description of the changes included in this pull request, also include relevant motivation and context. Have in mind the following questions: 1. What is the reason for the change? 2. What is the improvement/solution? --> Adds the Batch Sell review-screen loading and control states for Unified Swaps behind the `MM_BATCH_SELL_ENABLED` gate. This focuses the pre-quote review experience on skeleton placeholders, percent allocation controls, and destination stablecoin selection. The review screen now shows quote-dependent skeleton loaders for total received and per-token amounts, exposes discrete percent sliders that snap to supported allocation points, and lets users choose a destination stablecoin through a routed picker modal with fiat balance rows when available. ## **Changelog** <!-- If this PR is not End-User-Facing and should not show up in the CHANGELOG, you can choose to either: 1. Write `CHANGELOG entry: null` 2. Label with `no-changelog` If this PR is End-User-Facing, please write a short User-Facing description in the past tense like: `CHANGELOG entry: Added a new tab for users to see their NFTs` `CHANGELOG entry: Fixed a bug that was causing some NFTs to flicker` (This helps the Release Engineer do their job more quickly and accurately) --> CHANGELOG entry: Added Batch Sell review loading states, percent sliders, and destination stablecoin selection ## **Related issues** Refs: [SWAPS-4439](https://consensyssoftware.atlassian.net/browse/SWAPS-4439) ## **Manual testing steps** ```gherkin Feature: Batch Sell review loading states and controls Scenario: user reviews selected Batch Sell tokens before quotes are available Given MM_BATCH_SELL_ENABLED is true And swaps are active And the wallet has eligible non-stablecoin tokens with balances on a supported Batch Sell network When user opens the wallet actions sheet And user taps Batch Sell Then the Batch Sell token selection screen is displayed And eligible networks are shown as filter pills And stablecoins are excluded from the sell token list When user selects up to five tokens from the same network Then the Continue button reflects the number of selected tokens When user taps Continue Then the Batch Sell Review screen is displayed And skeleton loaders are displayed for quote-dependent total received and token amount values And each selected token appears with a percentage slider defaulted to 100% And the destination stablecoin pill is displayed Scenario: user changes the Batch Sell destination stablecoin Given user is on the Batch Sell Review screen And destination stablecoins are configured for the selected source network When user taps the destination stablecoin pill Then the stablecoin selector modal is displayed And each stablecoin row shows its fiat balance when available When user selects another stablecoin Then the selector closes And the selected stablecoin is used on the review screen Scenario: user adjusts Batch Sell token percentages Given user is on the Batch Sell Review screen When user drags or taps a token percentage slider Then the slider snaps to one of 0%, 25%, 50%, 75%, or 100% ``` ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> N/A ### **After** <!-- [screenshots/recordings] --> https://github.com/user-attachments/assets/ec5e666c-caba-4691-8d8b-717b6fc490f9 ## **Pre-merge author checklist** <!-- Every checklist item must be consciously assessed before marking this PR as "Ready for review". A checked box means you deliberately considered that responsibility, not that you literally performed every action listed. Unchecked boxes are ambiguous: they are not an implicit "N/A" and they are not a silent "skip". See `docs/readme/ready-for-review.md` for the full checklist semantics. --> - [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. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** <!-- Reviewer checklist items follow the same semantics as the author checklist: an unchecked box is ambiguous, a checked box means the reviewer consciously assessed that responsibility. See `docs/readme/ready-for-review.md`. --> - [ ] 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. <!-- Generated with the help of the pr-description AI skill --> [SWAPS-4439]: https://consensyssoftware.atlassian.net/browse/SWAPS-4439?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Adds new navigation routes and Redux state for Batch Sell destination token selection, plus new gesture-driven slider UI; risk is moderate due to new flow wiring and asset-id normalization that could affect token filtering/selection across chains. > > **Overview** > Introduces a new **Batch Sell review** screen with skeleton loading UI, per-token percentage allocation controls (snapping to 0/25/50/75/100 via a new gesture-based `BatchSellPercentageSlider`), and a destination stablecoin pill that opens a new bottom-sheet selector modal. > > Updates Batch Sell token selection to reset Batch Sell handoff state on entry, exclude destination stablecoins using normalized CAIP-19 asset IDs, and navigate multi-token flows to the new `Routes.BRIDGE.BATCH_SELL_REVIEW` route (while single-token flows still open the high-rate alert). > > Extends the bridge Redux slice with `batchSellDestToken`, adds selectors for destination stablecoins (now returned as local `BridgeToken` metadata with checksum-insensitive matching), expands `BridgeTokenMetadata` coverage, and adds `formatTokenBalance`/asset-id normalization utilities with accompanying tests. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 4956b5e. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 89d5d6c commit 58ca663

28 files changed

Lines changed: 2024 additions & 556 deletions
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import React from 'react';
2+
import { fireEvent, render } from '@testing-library/react-native';
3+
4+
import {
5+
BatchSellPercentageSlider,
6+
SNAP_POINTS,
7+
snapToPercentageStep,
8+
} from './BatchSellPercentageSlider';
9+
10+
const SLIDER_TEST_ID = 'batch-sell-percentage-slider';
11+
12+
jest.mock('@metamask/design-system-twrnc-preset', () => ({
13+
useTailwind: () => ({
14+
style: () => ({}),
15+
}),
16+
}));
17+
18+
jest.mock('react-native-gesture-handler', () => {
19+
const { View } = jest.requireActual('react-native');
20+
const gesture = {
21+
onEnd: jest.fn().mockReturnThis(),
22+
onUpdate: jest.fn().mockReturnThis(),
23+
};
24+
25+
return {
26+
GestureHandlerRootView: View,
27+
GestureDetector: ({ children }: { children: React.ReactNode }) => children,
28+
Gesture: {
29+
Pan: jest.fn(() => gesture),
30+
Tap: jest.fn(() => gesture),
31+
Simultaneous: jest.fn((...gestures) => gestures),
32+
},
33+
};
34+
});
35+
36+
describe('BatchSellPercentageSlider', () => {
37+
it.each([
38+
[-10, 0],
39+
[0, 0],
40+
[12, 0],
41+
[13, 25],
42+
[37, 25],
43+
[38, 50],
44+
[62, 50],
45+
[63, 75],
46+
[87, 75],
47+
[88, 100],
48+
[120, 100],
49+
])('snaps %s to %s', (value, expectedValue) => {
50+
const result = snapToPercentageStep(value);
51+
52+
expect(result).toBe(expectedValue);
53+
});
54+
55+
it('increments accessibility value by one snap point', () => {
56+
const onValueChange = jest.fn();
57+
const { getByTestId } = render(
58+
<BatchSellPercentageSlider
59+
value={50}
60+
onValueChange={onValueChange}
61+
testID={SLIDER_TEST_ID}
62+
/>,
63+
);
64+
65+
fireEvent(getByTestId(SLIDER_TEST_ID), 'accessibilityAction', {
66+
nativeEvent: { actionName: 'increment' },
67+
});
68+
69+
expect(onValueChange).toHaveBeenCalledWith(75);
70+
});
71+
72+
it('decrements accessibility value by one snap point', () => {
73+
const onValueChange = jest.fn();
74+
const { getByTestId } = render(
75+
<BatchSellPercentageSlider
76+
value={50}
77+
onValueChange={onValueChange}
78+
testID={SLIDER_TEST_ID}
79+
/>,
80+
);
81+
82+
fireEvent(getByTestId(SLIDER_TEST_ID), 'accessibilityAction', {
83+
nativeEvent: { actionName: 'decrement' },
84+
});
85+
86+
expect(onValueChange).toHaveBeenCalledWith(25);
87+
});
88+
89+
it('renders muted marker dots for each snap point', () => {
90+
const { getByTestId } = render(
91+
<BatchSellPercentageSlider
92+
value={50}
93+
onValueChange={jest.fn()}
94+
testID={SLIDER_TEST_ID}
95+
/>,
96+
);
97+
98+
SNAP_POINTS.forEach((snapPoint) => {
99+
expect(
100+
getByTestId(`${SLIDER_TEST_ID}-snap-point-${snapPoint}`),
101+
).toBeOnTheScreen();
102+
});
103+
});
104+
});
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import React, { useCallback, useEffect, useRef } from 'react';
2+
import { AccessibilityActionEvent, LayoutChangeEvent } from 'react-native';
3+
import {
4+
Gesture,
5+
GestureDetector,
6+
GestureHandlerRootView,
7+
} from 'react-native-gesture-handler';
8+
import Animated, {
9+
runOnJS,
10+
useAnimatedStyle,
11+
useSharedValue,
12+
} from 'react-native-reanimated';
13+
import { useTailwind } from '@metamask/design-system-twrnc-preset';
14+
15+
const HANDLE_SIZE = 24;
16+
const MARKER_SIZE = 4;
17+
const PERCENTAGE_STEP = 25;
18+
export const SNAP_POINTS = [0, 25, 50, 75, 100];
19+
20+
export function snapToPercentageStep(value: number): number {
21+
const snappedValue = Math.round(value / PERCENTAGE_STEP) * PERCENTAGE_STEP;
22+
return Math.max(0, Math.min(100, snappedValue));
23+
}
24+
25+
interface BatchSellPercentageSliderProps {
26+
value: number;
27+
onValueChange: (value: number) => void;
28+
testID?: string;
29+
}
30+
31+
export function BatchSellPercentageSlider({
32+
value,
33+
onValueChange,
34+
testID,
35+
}: BatchSellPercentageSliderProps) {
36+
const tw = useTailwind();
37+
const sliderWidth = useSharedValue(0);
38+
const translateX = useSharedValue(0);
39+
const widthRef = useRef(0);
40+
41+
const updatePosition = useCallback(
42+
(nextValue: number, width = widthRef.current) => {
43+
const snappedValue = snapToPercentageStep(nextValue);
44+
translateX.value = (snappedValue / 100) * width;
45+
},
46+
[translateX],
47+
);
48+
49+
const updateValueFromPosition = useCallback(
50+
(position: number, width: number) => {
51+
if (width === 0) {
52+
return;
53+
}
54+
55+
const clampedPosition = Math.max(0, Math.min(position, width));
56+
const nextValue = snapToPercentageStep((clampedPosition / width) * 100);
57+
58+
updatePosition(nextValue, width);
59+
onValueChange(nextValue);
60+
},
61+
[onValueChange, updatePosition],
62+
);
63+
64+
const handleLayout = useCallback(
65+
(event: LayoutChangeEvent) => {
66+
const { width } = event.nativeEvent.layout;
67+
widthRef.current = width;
68+
sliderWidth.value = width;
69+
updatePosition(value, width);
70+
},
71+
[sliderWidth, updatePosition, value],
72+
);
73+
74+
useEffect(() => {
75+
updatePosition(value);
76+
}, [updatePosition, value]);
77+
78+
const progressStyle = useAnimatedStyle(() => ({
79+
width: translateX.value,
80+
}));
81+
82+
const handleStyle = useAnimatedStyle(() => {
83+
const handleOffset = Math.max(
84+
0,
85+
Math.min(
86+
translateX.value - HANDLE_SIZE / 2,
87+
sliderWidth.value - HANDLE_SIZE,
88+
),
89+
);
90+
91+
return {
92+
transform: [{ translateX: handleOffset }],
93+
};
94+
});
95+
96+
const gesture = Gesture.Simultaneous(
97+
Gesture.Tap().onEnd((event) => {
98+
runOnJS(updateValueFromPosition)(event.x, sliderWidth.value);
99+
}),
100+
Gesture.Pan().onUpdate((event) => {
101+
runOnJS(updateValueFromPosition)(event.x, sliderWidth.value);
102+
}),
103+
);
104+
105+
const handleAccessibilityAction = useCallback(
106+
(event: AccessibilityActionEvent) => {
107+
const nextValue =
108+
event.nativeEvent.actionName === 'increment'
109+
? snapToPercentageStep(value + PERCENTAGE_STEP)
110+
: snapToPercentageStep(value - PERCENTAGE_STEP);
111+
112+
onValueChange(nextValue);
113+
},
114+
[onValueChange, value],
115+
);
116+
117+
return (
118+
<GestureHandlerRootView
119+
testID={testID}
120+
accessibilityRole="adjustable"
121+
accessibilityValue={{ min: 0, max: 100, now: value, text: `${value}%` }}
122+
accessibilityActions={[{ name: 'increment' }, { name: 'decrement' }]}
123+
onAccessibilityAction={handleAccessibilityAction}
124+
style={tw.style('h-6 w-full')}
125+
>
126+
<GestureDetector gesture={gesture}>
127+
<Animated.View
128+
onLayout={handleLayout}
129+
style={tw.style('h-6 w-full justify-center')}
130+
>
131+
<Animated.View
132+
style={tw.style(
133+
'absolute left-0 right-0 h-1 rounded-full bg-icon-muted',
134+
)}
135+
/>
136+
<Animated.View
137+
style={[
138+
tw.style('absolute left-0 h-1 rounded-full bg-icon-default'),
139+
progressStyle,
140+
]}
141+
/>
142+
{SNAP_POINTS.map((snapPoint) => (
143+
<Animated.View
144+
key={snapPoint}
145+
pointerEvents="none"
146+
testID={testID ? `${testID}-snap-point-${snapPoint}` : undefined}
147+
style={[
148+
tw.style('absolute h-1 w-1 rounded-full bg-icon-muted'),
149+
{
150+
left: `${snapPoint}%`,
151+
transform: [{ translateX: -MARKER_SIZE / 2 }],
152+
},
153+
]}
154+
/>
155+
))}
156+
<Animated.View
157+
style={[
158+
tw.style('absolute h-6 w-6 rounded-full bg-icon-default'),
159+
handleStyle,
160+
]}
161+
/>
162+
</Animated.View>
163+
</GestureDetector>
164+
</GestureHandlerRootView>
165+
);
166+
}

0 commit comments

Comments
 (0)