Skip to content

Commit 2f07682

Browse files
committed
feat: wallet v4 tour
1 parent b91fa4d commit 2f07682

File tree

8 files changed

+374
-55
lines changed

8 files changed

+374
-55
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"live-mobile": minor
3+
"@ledgerhq/native-ui": minor
4+
---
5+
6+
Add Wallet V4 Tour drawer with slides showcasing new features

apps/ledger-live-mobile/src/locales/en/common.json

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8409,5 +8409,26 @@
84098409
"actions": {
84108410
"swap": "Swap"
84118411
}
8412+
},
8413+
"walletV4Tour": {
8414+
"title": "Discover your new portfolio",
8415+
"slides": {
8416+
"portfolio": {
8417+
"title": "Your portfolio, reimagined",
8418+
"description": "We've decluttered your home screen. Missing your graph? Just tap below your balance."
8419+
},
8420+
"navigation": {
8421+
"title": "Everything in its right place",
8422+
"description": "My Ledger & Discover are now at the top. Swap & Card have moved to the bottom for faster access."
8423+
},
8424+
"actions": {
8425+
"title": "Actions at market speed",
8426+
"description": "Spot market trends with the new banner and trade instantly."
8427+
}
8428+
},
8429+
"cta": {
8430+
"continue": "Continue",
8431+
"explore": "Explore my new portfolio"
8432+
}
84128433
}
84138434
}
Lines changed: 67 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,23 @@
11
import React from "react";
22
import { Button } from "react-native";
3-
import { createNativeStackNavigator } from "@react-navigation/native-stack";
4-
import { render, screen, waitFor } from "@tests/test-renderer";
3+
import { fireEvent, render, screen, waitFor } from "@tests/test-renderer";
54
import { useWalletV4TourDrawer, WalletV4TourDrawer } from "../index";
65

7-
const Stack = createNativeStackNavigator();
8-
9-
const TestScreen = () => {
6+
const TestComponent = () => {
107
const { isDrawerOpen, handleOpenDrawer, handleCloseDrawer } = useWalletV4TourDrawer();
118
return (
129
<>
1310
<Button onPress={handleOpenDrawer} title="Open Drawer" />
14-
<Button onPress={handleCloseDrawer} title="Close Drawer" />
15-
<WalletV4TourDrawer isDrawerOpen={isDrawerOpen} handleCloseDrawer={handleCloseDrawer} />
11+
{isDrawerOpen && (
12+
<WalletV4TourDrawer isDrawerOpen={isDrawerOpen} handleCloseDrawer={handleCloseDrawer} />
13+
)}
1614
</>
1715
);
1816
};
1917

20-
const TestNavigator = () => (
21-
<Stack.Navigator screenOptions={{ headerShown: false }}>
22-
<Stack.Screen name="Test" component={TestScreen} />
23-
</Stack.Navigator>
24-
);
25-
2618
describe("WalletV4TourDrawer integration", () => {
27-
it("should persist seen state when closed", async () => {
28-
const { store, user } = render(<TestNavigator />, {
19+
function renderTestComponent() {
20+
const rendered = render(<TestComponent />, {
2921
overrideInitialState: state => ({
3022
...state,
3123
settings: {
@@ -35,25 +27,72 @@ describe("WalletV4TourDrawer integration", () => {
3527
}),
3628
});
3729

30+
// Run this to make sure Slides is rendered as it requires its container's width > 0
31+
const resizeScreenWidth = () => {
32+
const slidesContainer = screen.getByTestId("walletv4-tour-slides-container");
33+
fireEvent(slidesContainer, "layout", {
34+
nativeEvent: { layout: { width: 375, height: 800 } },
35+
});
36+
};
37+
38+
return {
39+
...rendered,
40+
resizeScreenWidth,
41+
};
42+
}
43+
44+
const SLIDES = [
45+
{
46+
title: "Your portfolio, reimagined",
47+
description:
48+
"We've decluttered your home screen. Missing your graph? Just tap below your balance.",
49+
},
50+
{
51+
title: "Everything in its right place",
52+
description:
53+
"My Ledger & Discover are now at the top. Swap & Card have moved to the bottom for faster access.",
54+
},
55+
{
56+
title: "Actions at market speed",
57+
description: "Spot market trends with the new banner and trade instantly.",
58+
},
59+
];
60+
61+
it("should be able to open the drawer and see all the slides", async () => {
62+
const { user, resizeScreenWidth } = renderTestComponent();
63+
3864
await user.press(screen.getByText("Open Drawer"));
39-
await user.press(screen.getByText("Close Drawer"));
4065

41-
await waitFor(() => expect(store.getState().settings.hasSeenWalletV4Tour).toBe(true));
42-
});
66+
resizeScreenWidth();
4367

44-
it("should not open drawer when tour has already been seen", async () => {
45-
const { store, user } = render(<TestNavigator />, {
46-
overrideInitialState: state => ({
47-
...state,
48-
settings: {
49-
...state.settings,
50-
hasSeenWalletV4Tour: true,
51-
},
52-
}),
68+
await waitFor(() => expect(screen.getByText("Your portfolio, reimagined")).toBeOnTheScreen());
69+
SLIDES.forEach(slide => {
70+
expect(screen.getByText(slide.title)).toBeOnTheScreen();
71+
expect(screen.getByText(slide.description)).toBeOnTheScreen();
5372
});
73+
});
74+
75+
it('should close the drawer and never show again when user presses the "Explore my new portfolio" button on the last slide', async () => {
76+
const { user, resizeScreenWidth } = renderTestComponent();
5477

5578
await user.press(screen.getByText("Open Drawer"));
5679

57-
expect(store.getState().settings.hasSeenWalletV4Tour).toBe(true);
80+
resizeScreenWidth();
81+
82+
const continueButtons = screen.getAllByRole("button", { name: "Continue" });
83+
84+
for (const continueButton of continueButtons) {
85+
await user.press(continueButton);
86+
}
87+
88+
const exploreButton = screen.getByRole("button", { name: "Explore my new portfolio" });
89+
await user.press(exploreButton);
90+
91+
// should never show again
92+
await user.press(screen.getByText("Open Drawer"));
93+
SLIDES.forEach(slide => {
94+
expect(screen.queryByText(slide.title)).not.toBeOnTheScreen();
95+
expect(screen.queryByText(slide.description)).not.toBeOnTheScreen();
96+
});
5897
});
5998
});
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import React, { useMemo } from "react";
2+
import { StyleSheet, View } from "react-native";
3+
import Animated, { interpolate, interpolateColor, useAnimatedStyle } from "react-native-reanimated";
4+
import { useSlidesContext } from "@ledgerhq/native-ui";
5+
6+
interface DotProps {
7+
readonly index: number;
8+
}
9+
10+
const Dot = ({ index }: DotProps) => {
11+
const { scrollProgressSharedValue } = useSlidesContext();
12+
13+
const inputRange = useMemo(() => [index - 1, index, index + 1], [index]);
14+
15+
const animatedStyle = useAnimatedStyle(() => {
16+
const scale = interpolate(scrollProgressSharedValue.value, inputRange, [1, 1.25, 1], "clamp");
17+
const opacity = interpolate(
18+
scrollProgressSharedValue.value,
19+
inputRange,
20+
[0.5, 1, 0.5],
21+
"clamp",
22+
);
23+
const backgroundColor = interpolateColor(scrollProgressSharedValue.value, inputRange, [
24+
"#ccc",
25+
"#000",
26+
"#ccc",
27+
]);
28+
29+
return { transform: [{ scale }], opacity, backgroundColor };
30+
}, [scrollProgressSharedValue.value, inputRange]);
31+
32+
return <Animated.View style={[styles.dot, animatedStyle]} />;
33+
};
34+
35+
export const ProgressDots = () => {
36+
const { totalSlides } = useSlidesContext();
37+
38+
return (
39+
<View style={styles.container}>
40+
{Array.from({ length: totalSlides }, (_, index) => (
41+
<Dot key={index} index={index} />
42+
))}
43+
</View>
44+
);
45+
};
46+
47+
const styles = StyleSheet.create({
48+
container: {
49+
flexDirection: "row",
50+
alignItems: "center",
51+
justifyContent: "center",
52+
gap: 8,
53+
},
54+
dot: {
55+
width: 8,
56+
height: 8,
57+
borderRadius: 4,
58+
},
59+
});
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import React from "react";
2+
import { StyleSheet } from "react-native";
3+
import Animated, { interpolate, useAnimatedStyle } from "react-native-reanimated";
4+
import { useSlidesContext } from "@ledgerhq/native-ui";
5+
import { useTranslation } from "~/context/Locale";
6+
import { Button } from "@ledgerhq/lumen-ui-rnative";
7+
8+
interface SlideFooterButtonProps {
9+
readonly onClose: () => void;
10+
}
11+
12+
export const SlideFooterButton = ({ onClose }: SlideFooterButtonProps) => {
13+
const { totalSlides, goToNext, scrollProgressSharedValue } = useSlidesContext();
14+
const { t } = useTranslation();
15+
16+
const lastIndex = totalSlides - 1;
17+
const fadeStart = lastIndex - 0.5;
18+
19+
const continueStyle = useAnimatedStyle(
20+
() => ({
21+
opacity: interpolate(
22+
scrollProgressSharedValue.value,
23+
[fadeStart, lastIndex],
24+
[1, 0],
25+
"clamp",
26+
),
27+
}),
28+
[fadeStart, lastIndex, scrollProgressSharedValue.value],
29+
);
30+
31+
const exploreStyle = useAnimatedStyle(
32+
() => ({
33+
opacity: interpolate(
34+
scrollProgressSharedValue.value,
35+
[fadeStart, lastIndex],
36+
[0, 1],
37+
"clamp",
38+
),
39+
}),
40+
[fadeStart, lastIndex, scrollProgressSharedValue.value],
41+
);
42+
43+
return (
44+
<Animated.View style={styles.container}>
45+
<Animated.View style={[styles.button, continueStyle]} pointerEvents="box-none">
46+
<Button appearance="base" size="md" onPress={goToNext}>
47+
{t("walletV4Tour.cta.continue")}
48+
</Button>
49+
</Animated.View>
50+
51+
<Animated.View style={[styles.button, exploreStyle]} pointerEvents="box-none">
52+
<Button appearance="base" size="md" onPress={onClose}>
53+
{t("walletV4Tour.cta.explore")}
54+
</Button>
55+
</Animated.View>
56+
</Animated.View>
57+
);
58+
};
59+
60+
const styles = StyleSheet.create({
61+
container: {
62+
position: "relative",
63+
},
64+
button: {
65+
position: "absolute",
66+
top: 0,
67+
left: 0,
68+
right: 0,
69+
},
70+
});
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import React, { useCallback, useState } from "react";
2+
import { type LayoutChangeEvent } from "react-native";
3+
import { useSlidesContext } from "@ledgerhq/native-ui";
4+
import { Box, Text } from "@ledgerhq/lumen-ui-rnative";
5+
import Animated, { interpolate, useAnimatedStyle } from "react-native-reanimated";
6+
7+
interface SlideItemProps {
8+
readonly title: string;
9+
readonly description: string;
10+
readonly index: number;
11+
}
12+
13+
export function SlideItem({ title, description, index }: SlideItemProps) {
14+
const { scrollProgressSharedValue } = useSlidesContext();
15+
const [slideWidth, setSlideWidth] = useState(0);
16+
17+
const handleLayout = useCallback(({ nativeEvent: { layout } }: LayoutChangeEvent) => {
18+
setSlideWidth(layout.width);
19+
}, []);
20+
21+
const animatedStyle = useAnimatedStyle(() => {
22+
const progress = scrollProgressSharedValue.value;
23+
return {
24+
opacity: interpolate(progress, [index - 1, index, index + 1], [0, 1, 0]),
25+
transform: [
26+
{
27+
translateX: interpolate(
28+
progress,
29+
[index - 1, index, index + 1],
30+
[-slideWidth, 0, slideWidth],
31+
),
32+
},
33+
],
34+
};
35+
}, [index, slideWidth, scrollProgressSharedValue.value]);
36+
37+
return (
38+
<Animated.View onLayout={handleLayout} style={[animatedStyle, { flex: 1 }]}>
39+
<Box
40+
lx={{
41+
flex: 1,
42+
marginBottom: "s40",
43+
alignItems: "center",
44+
justifyContent: "center",
45+
}}
46+
>
47+
<Box
48+
lx={{
49+
width: "s256",
50+
height: "s320",
51+
backgroundColor: "active",
52+
}}
53+
/>
54+
</Box>
55+
56+
<Text
57+
typography="heading2SemiBold"
58+
lx={{
59+
textAlign: "center",
60+
marginBottom: "s4",
61+
}}
62+
numberOfLines={1}
63+
>
64+
{title}
65+
</Text>
66+
67+
<Text
68+
typography="body2"
69+
lx={{
70+
marginTop: "s8",
71+
textAlign: "center",
72+
}}
73+
numberOfLines={2}
74+
>
75+
{description}
76+
</Text>
77+
</Animated.View>
78+
);
79+
}

0 commit comments

Comments
 (0)