Skip to content

Commit a8df8e5

Browse files
authored
feature: decoupled claim airdrop flow (#7262)
Fixes APP-3535 ## Why The airdrop claim flow was coupled to the rewards screen, which manages ~9 scenes (intro, eligibility, claim, rewards overview, etc.). To ship the membership screen independently, airdrop claiming needed its own standalone screen — navigable from the membership tab, no rewards scene management involved. Both flows coexist behind a feature flag, so the duplication is intentional. ## Changes **Direct copies / trimmed only** - `airdropScenes.ts` **Copies with modifications** - `AmbientCoins.tsx` — swapped context for zustand store, simplified to 2 visibility states, tweaked animation params - `BottomGradientGlow.tsx` — dropped airdrop balance check, reduced gradient config from 4 scenes to 2 - `RnbwHeroCoin.tsx` — 3-scene config instead of 8, new Y positions, stripped spin/blur animations for removed scenes - `AirdropClaimPromptScene.tsx` — removed "Claim Later" button (no rewards overview to fall back to), wired to airdrop store - `AirdropClaimedScene.tsx` — "Done" calls `goBack()` instead of navigating to rewards overview - `airdropFlowStore.ts` — stripped eligibility check, rewards claim, and configurable `resetFlow()` from `rewardsFlowStore.ts` **New files** - `RnbwAirdropScreen.tsx` — full-screen modal composing the above with a 3-scene router - `AirdropClaimingScene.tsx` — loading/status scene using existing `ActionStatusScene` - `RnbwClaimCard.tsx` — card component for the membership screen **Common pattern across copied files:** replace rewards flow context/store with `useAirdropFlowStore`, strip inapplicable scenes, simplify animations accordingly. **Screen Recording** https://github.com/user-attachments/assets/af1eceea-00d9-4287-b789-c1f64d56e96d ## What to test I'm not aware of wallets we control that have airdrops left to claim, so this is difficult to actually test. For the demo above I locally changed it so that it mocked a successful response. Given nothing about the API call itself changed, I think that is sufficient.
1 parent a3bd7ce commit a8df8e5

15 files changed

Lines changed: 349 additions & 238 deletions

File tree

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { memo, useEffect } from 'react';
2+
import { StyleSheet, View } from 'react-native';
3+
import { useSafeAreaInsets } from 'react-native-safe-area-context';
4+
import SheetHandleFixedToTop from '@/components/sheet/SheetHandleFixedToTop';
5+
import { RnbwHeroCoin } from '@/features/rnbw-airdrop/screens/rnbw-airdrop-screen/components/RnbwHeroCoin';
6+
import { AmbientCoins } from '@/features/rnbw-airdrop/screens/rnbw-airdrop-screen/components/AmbientCoins';
7+
import { BottomGradientGlow } from '@/features/rnbw-airdrop/screens/rnbw-airdrop-screen/components/BottomGradientGlow';
8+
import { RnbwAirdropScenes } from '@/features/rnbw-airdrop/screens/rnbw-airdrop-screen/constants/airdropScenes';
9+
import { ColorModeProvider } from '@/design-system';
10+
import { useAirdropFlowStore, airdropFlowActions } from '@/features/rnbw-airdrop/stores/airdropFlowStore';
11+
import { AirdropClaimPromptScene } from '@/features/rnbw-airdrop/screens/rnbw-airdrop-screen/scenes/AirdropClaimPromptScene';
12+
import { AirdropClaimingScene } from '@/features/rnbw-airdrop/screens/rnbw-airdrop-screen/scenes/AirdropClaimingScene';
13+
import { AirdropClaimedScene } from '@/features/rnbw-airdrop/screens/rnbw-airdrop-screen/scenes/AirdropClaimedScene';
14+
15+
export const RnbwAirdropScreen = memo(function RnbwAirdropScreen() {
16+
const { top: safeAreaTop, bottom: safeAreaBottom } = useSafeAreaInsets();
17+
18+
useEffect(() => {
19+
airdropFlowActions.reset();
20+
}, []);
21+
22+
return (
23+
<ColorModeProvider value="dark">
24+
<View style={styles.container} testID="rnbw-airdrop-screen">
25+
<SheetHandleFixedToTop color="rgba(245, 248, 255, 0.3)" top={safeAreaTop + 6} />
26+
<View style={[styles.flex, { marginTop: safeAreaTop, paddingBottom: safeAreaBottom }]}>
27+
<BottomGradientGlow />
28+
<AmbientCoins />
29+
<RnbwHeroCoin />
30+
<RnbwAirdropSceneRouter />
31+
</View>
32+
</View>
33+
</ColorModeProvider>
34+
);
35+
});
36+
37+
function RnbwAirdropSceneRouter() {
38+
const activeScene = useAirdropFlowStore(state => state.activeScene);
39+
40+
return (
41+
<View style={styles.flex}>
42+
{activeScene === RnbwAirdropScenes.AirdropClaimPrompt && <AirdropClaimPromptScene />}
43+
{activeScene === RnbwAirdropScenes.AirdropClaiming && <AirdropClaimingScene />}
44+
{activeScene === RnbwAirdropScenes.AirdropClaimed && <AirdropClaimedScene />}
45+
</View>
46+
);
47+
}
48+
49+
const styles = StyleSheet.create({
50+
container: {
51+
flex: 1,
52+
backgroundColor: '#0D0D0D',
53+
},
54+
flex: {
55+
flex: 1,
56+
},
57+
});

src/features/rnbw-airdrop/screens/rnbw-airdrop-screen/components/AmbientCoins.tsx

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,10 @@ import { BlurView } from 'react-native-blur-view';
1818
import rnbwCoin from '@/assets/rnbw.png';
1919
import useTimeout from '@/hooks/useTimeout';
2020
import { time } from '@/utils/time';
21-
import { RnbwRewardsScenes } from '@/features/rnbw-rewards/screens/rnbw-rewards-screen/constants/rewardsScenes';
22-
import { useRnbwRewardsFlowContext } from '@/features/rnbw-rewards/screens/rnbw-rewards-screen/context/RnbwRewardsFlowContext';
23-
import { getCoinBottomPosition, getCoinCenterPosition } from '@/features/rnbw-rewards/screens/rnbw-rewards-screen/components/RnbwHeroCoin';
21+
import { RnbwAirdropScenes } from '@/features/rnbw-airdrop/screens/rnbw-airdrop-screen/constants/airdropScenes';
22+
import { useAirdropFlowStore } from '@/features/rnbw-airdrop/stores/airdropFlowStore';
23+
import { useStoreSharedValue } from '@/state/internal/hooks/useStoreSharedValue';
24+
import { getCoinCenterPosition } from '@/features/rnbw-airdrop/screens/rnbw-airdrop-screen/components/RnbwHeroCoin';
2425

2526
// Original design dimensions
2627
const DESIGN_WIDTH = 393;
@@ -87,7 +88,7 @@ const COINS: CoinConfig[] = [
8788
pattern: 'a',
8889
duration: time.seconds(14),
8990
exitX: -48,
90-
exitY: -54,
91+
exitY: -100,
9192
},
9293
{
9394
left: 348,
@@ -108,7 +109,7 @@ const COINS: CoinConfig[] = [
108109
blur: 1,
109110
pattern: 'd',
110111
duration: time.seconds(20),
111-
exitX: 72,
112+
exitX: 96,
112113
exitY: 60,
113114
},
114115
{
@@ -141,8 +142,9 @@ const AmbientCoin = memo(function AmbientCoin({
141142

142143
const { left, top, size } = config;
143144

144-
const claimedLeft = getCoinCenterPosition(RnbwRewardsScenes.AirdropClaimed).x - size / 2;
145-
const claimedTop = getCoinBottomPosition(RnbwRewardsScenes.AirdropClaimed);
145+
const claimedCenter = getCoinCenterPosition(RnbwAirdropScenes.AirdropClaimed);
146+
const claimedLeft = claimedCenter.x - size / 2;
147+
const claimedTop = claimedCenter.y - size / 2;
146148

147149
const startFloatingAnimation = useCallback(() => {
148150
'worklet';
@@ -197,6 +199,8 @@ const AmbientCoin = memo(function AmbientCoin({
197199
// Normalize distance to 0-1 from 200-300
198200
const normalizedDistance = Math.max(0, Math.min(1, (distanceFromClaimPosition - 200) / 100));
199201
delay = normalizedDistance * time.ms(100);
202+
// Reset to offscreen starting position so coins fly in from edges
203+
exitProgress.value = 0;
200204
}
201205

202206
exitProgress.value = withDelay(
@@ -213,8 +217,8 @@ const AmbientCoin = memo(function AmbientCoin({
213217
const containerStyle = useAnimatedStyle(() => {
214218
if (state.value === 'claimed') {
215219
return {
216-
left: interpolate(exitProgress.value, [0, 1], [left, claimedLeft]),
217-
top: interpolate(exitProgress.value, [0, 1], [top, claimedTop]),
220+
left: interpolate(exitProgress.value, [0, 1], [left + config.exitX, claimedLeft]),
221+
top: interpolate(exitProgress.value, [0, 1], [top + config.exitY, claimedTop]),
218222
transform: [{ translateX: floatX.value }, { translateY: floatY.value }],
219223
};
220224
}
@@ -292,18 +296,15 @@ const _AmbientCoins = memo(function _AmbientCoins({ state, entering }: { state:
292296
});
293297

294298
export const AmbientCoins = memo(function AmbientCoins() {
295-
const { activeScene } = useRnbwRewardsFlowContext();
299+
const activeScene = useStoreSharedValue(useAirdropFlowStore, state => state.activeScene, { fireImmediately: true });
296300
const [mountState, setMountState] = useState({ shouldRender: true, entering: false });
297301
const [startHideTimeout, stopHideTimeout] = useTimeout();
298302

299303
const state = useDerivedValue(() => {
300304
switch (activeScene.value) {
301-
case RnbwRewardsScenes.AirdropIntro:
302-
case RnbwRewardsScenes.AirdropClaimPrompt:
305+
case RnbwAirdropScenes.AirdropClaimPrompt:
303306
return 'visible';
304-
case RnbwRewardsScenes.AirdropEligibility:
305-
return 'hidden';
306-
case RnbwRewardsScenes.AirdropClaimed:
307+
case RnbwAirdropScenes.AirdropClaimed:
307308
return 'claimed';
308309
default:
309310
return 'hidden';

src/features/rnbw-airdrop/screens/rnbw-airdrop-screen/components/BottomGradientGlow.tsx

Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
import { type RnbwRewardsScene, RnbwRewardsScenes } from '@/features/rnbw-rewards/screens/rnbw-rewards-screen/constants/rewardsScenes';
2-
import { useRnbwRewardsFlowContext } from '@/features/rnbw-rewards/screens/rnbw-rewards-screen/context/RnbwRewardsFlowContext';
1+
import { type RnbwAirdropScene, RnbwAirdropScenes } from '@/features/rnbw-airdrop/screens/rnbw-airdrop-screen/constants/airdropScenes';
2+
import { useAirdropFlowStore } from '@/features/rnbw-airdrop/stores/airdropFlowStore';
3+
import { useStoreSharedValue } from '@/state/internal/hooks/useStoreSharedValue';
34
import { Blur, Canvas, LinearGradient, RoundedRect, vec } from '@shopify/react-native-skia';
45
import { interpolate, interpolateColor, useAnimatedReaction, useDerivedValue, useSharedValue, withTiming } from 'react-native-reanimated';
56
import { memo, useMemo } from 'react';
67
import useDimensions from '@/hooks/useDimensions';
78
import { time } from '@/utils/time';
8-
import { useAirdropBalanceStore } from '@/features/rnbw-rewards/stores/airdropBalanceStore';
99

1010
type GradientConfig = {
1111
colors: readonly string[];
@@ -15,7 +15,7 @@ type GradientConfig = {
1515
};
1616

1717
type SceneGradient = {
18-
scene: RnbwRewardsScene;
18+
scene: RnbwAirdropScene;
1919
gradient: GradientConfig;
2020
opacity: number;
2121
};
@@ -40,31 +40,28 @@ const BLUE_ORANGE_GRADIENT: GradientConfig = {
4040

4141
export const BottomGradientGlow = memo(function BottomGradientGlow() {
4242
const { width: screenWidth, height: screenHeight } = useDimensions();
43-
const { activeScene } = useRnbwRewardsFlowContext();
44-
const hasClaimableAirdrop = useAirdropBalanceStore(state => state.hasClaimableAirdrop());
43+
const activeScene = useStoreSharedValue(useAirdropFlowStore, state => state.activeScene, { fireImmediately: true });
4544

4645
const config = useMemo(() => {
4746
const sceneGradients: SceneGradient[] = [
48-
{ scene: RnbwRewardsScenes.AirdropClaimPrompt, gradient: BLUE_ORANGE_GRADIENT, opacity: 0.2 },
49-
...(hasClaimableAirdrop ? [{ scene: RnbwRewardsScenes.RewardsOverview, gradient: BLUE_ORANGE_GRADIENT, opacity: 0.12 }] : []),
50-
{ scene: RnbwRewardsScenes.AirdropClaimed, gradient: BLUE_ORANGE_GRADIENT, opacity: 0.12 },
51-
{ scene: RnbwRewardsScenes.RewardsClaimed, gradient: BLUE_ORANGE_GRADIENT, opacity: 0.12 },
47+
{ scene: RnbwAirdropScenes.AirdropClaimPrompt, gradient: BLUE_ORANGE_GRADIENT, opacity: 0.2 },
48+
{ scene: RnbwAirdropScenes.AirdropClaimed, gradient: BLUE_ORANGE_GRADIENT, opacity: 0.12 },
5249
];
5350

5451
const inputRange = sceneGradients.map((_, index) => index);
55-
const sceneIndex = sceneGradients.reduce<Partial<Record<RnbwRewardsScene, number>>>(
52+
const sceneIndex = sceneGradients.reduce<Partial<Record<RnbwAirdropScene, number>>>(
5653
(acc, item, index) => {
5754
acc[item.scene] = index;
5855
return acc;
5956
},
60-
{} as Partial<Record<RnbwRewardsScene, number>>
57+
{} as Partial<Record<RnbwAirdropScene, number>>
6158
);
62-
const sceneOpacity = sceneGradients.reduce<Partial<Record<RnbwRewardsScene, number>>>(
59+
const sceneOpacity = sceneGradients.reduce<Partial<Record<RnbwAirdropScene, number>>>(
6360
(acc, item) => {
6461
acc[item.scene] = item.opacity;
6562
return acc;
6663
},
67-
{} as Partial<Record<RnbwRewardsScene, number>>
64+
{} as Partial<Record<RnbwAirdropScene, number>>
6865
);
6966

7067
const gradientSequence = sceneGradients.map(item => item.gradient);
@@ -99,7 +96,7 @@ export const BottomGradientGlow = memo(function BottomGradientGlow() {
9996
endX,
10097
endY,
10198
};
102-
}, [hasClaimableAirdrop]);
99+
}, []);
103100

104101
const sceneProgress = useSharedValue(0);
105102
const opacity = useSharedValue(0);

0 commit comments

Comments
 (0)