feat : ParticipationSegment

This commit is contained in:
Yaël Perret
2026-09-01 15:22:42 +02:00
parent 5a865d5635
commit 4290351ca5
4 changed files with 392 additions and 0 deletions

10
package-lock.json generated
View File

@@ -24,6 +24,7 @@
"expo-device": "~56.0.4", "expo-device": "~56.0.4",
"expo-font": "~56.0.7", "expo-font": "~56.0.7",
"expo-glass-effect": "~56.0.4", "expo-glass-effect": "~56.0.4",
"expo-haptics": "~56.0.3",
"expo-image": "~56.0.12", "expo-image": "~56.0.12",
"expo-linear-gradient": "~56.0.4", "expo-linear-gradient": "~56.0.4",
"expo-linking": "~56.0.12", "expo-linking": "~56.0.12",
@@ -4876,6 +4877,15 @@
"react-native": "*" "react-native": "*"
} }
}, },
"node_modules/expo-haptics": {
"version": "56.0.3",
"resolved": "https://registry.npmjs.org/expo-haptics/-/expo-haptics-56.0.3.tgz",
"integrity": "sha512-ycoahZJnR9tWAVh/0mJYxbETtHRYaWjiWS8cHlP6aDGU6Q6Y8rZ5NKsuBwWw6HR2Pe30mfVFgbF2HrBR6gtYmw==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-image": { "node_modules/expo-image": {
"version": "56.0.12", "version": "56.0.12",
"resolved": "https://registry.npmjs.org/expo-image/-/expo-image-56.0.12.tgz", "resolved": "https://registry.npmjs.org/expo-image/-/expo-image-56.0.12.tgz",

View File

@@ -19,6 +19,7 @@
"expo-device": "~56.0.4", "expo-device": "~56.0.4",
"expo-font": "~56.0.7", "expo-font": "~56.0.7",
"expo-glass-effect": "~56.0.4", "expo-glass-effect": "~56.0.4",
"expo-haptics": "~56.0.3",
"expo-image": "~56.0.12", "expo-image": "~56.0.12",
"expo-linear-gradient": "~56.0.4", "expo-linear-gradient": "~56.0.4",
"expo-linking": "~56.0.12", "expo-linking": "~56.0.12",

View File

@@ -4,6 +4,7 @@ import { ScrollView, StyleSheet, Text, View } from 'react-native';
import { Badge } from '@/components/Badge'; import { Badge } from '@/components/Badge';
import { Button } from '@/components/Button'; import { Button } from '@/components/Button';
import { ParticipationSegment } from '@/components/ParticipationSegment';
import { ThemeChip } from '@/components/ThemeChip'; import { ThemeChip } from '@/components/ThemeChip';
import { useTheme } from '@/theme/useTheme'; import { useTheme } from '@/theme/useTheme';
@@ -125,6 +126,14 @@ export default function Index() {
</View> </View>
</View> </View>
<View style={{ gap: spacing[12] }}>
<Text style={[typography.sectionLabel, { color: colors.textSecondary, letterSpacing: 1.1 }]}>PARTICIPATION</Text>
<View style={{ gap: spacing[12] }}>
<ParticipationSegment defaultValue={{ going: false, interested: false }} />
<ParticipationSegment defaultValue={{ going: true, interested: true }} />
</View>
</View>
<View style={{ gap: spacing[12] }}> <View style={{ gap: spacing[12] }}>
<Text style={[typography.sectionLabel, { color: colors.textSecondary, letterSpacing: 1.1 }]}>BADGES</Text> <Text style={[typography.sectionLabel, { color: colors.textSecondary, letterSpacing: 1.1 }]}>BADGES</Text>
<View style={styles.badgeWrap}> <View style={styles.badgeWrap}>

View File

@@ -0,0 +1,372 @@
import * as Haptics from 'expo-haptics';
import { LinearGradient } from 'expo-linear-gradient';
import { Check, Heart } from 'lucide-react-native';
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
ActivityIndicator,
Pressable,
StyleSheet,
Text,
View,
type StyleProp,
type ViewStyle,
} from 'react-native';
import Animated, {
Easing,
interpolate,
useAnimatedStyle,
useSharedValue,
withTiming,
} from 'react-native-reanimated';
import { useTheme } from '@/theme/useTheme';
type ParticipationValue = {
going: boolean;
interested: boolean;
};
type ParticipationSegmentProps = {
value?: ParticipationValue;
defaultValue?: Partial<ParticipationValue>;
onChange?: (nextValue: ParticipationValue) => void;
onToggleGoing?: (nextGoing: boolean) => void;
onToggleInterested?: (nextInterested: boolean) => void;
disabled?: boolean;
loadingGoing?: boolean;
loadingInterested?: boolean;
style?: StyleProp<ViewStyle>;
testID?: string;
};
const BUTTON_HEIGHT = 52;
const TRANSITION_DURATION = 180;
const ICON_SIZE = 16;
const SPINNER_SIZE = 18;
const AnimatedView = Animated.createAnimatedComponent(View);
export function ParticipationSegment({
value,
defaultValue,
onChange,
onToggleGoing,
onToggleInterested,
disabled = false,
loadingGoing = false,
loadingInterested = false,
style,
testID,
}: ParticipationSegmentProps) {
const { colors, gradients, gradientDirection, typography, borderRadius, glow } = useTheme();
const [internalValue, setInternalValue] = useState<ParticipationValue>({
going: defaultValue?.going ?? false,
interested: defaultValue?.interested ?? false,
});
const resolvedValue = value ?? internalValue;
const goingProgress = useSharedValue(resolvedValue.going ? 1 : 0);
const interestedProgress = useSharedValue(resolvedValue.interested ? 1 : 0);
useEffect(() => {
goingProgress.value = withTiming(resolvedValue.going ? 1 : 0, {
duration: TRANSITION_DURATION,
easing: Easing.out(Easing.cubic),
});
}, [goingProgress, resolvedValue.going]);
useEffect(() => {
interestedProgress.value = withTiming(resolvedValue.interested ? 1 : 0, {
duration: TRANSITION_DURATION,
easing: Easing.out(Easing.cubic),
});
}, [interestedProgress, resolvedValue.interested]);
const commitChange = useCallback(
(nextValue: ParticipationValue) => {
if (!value) {
setInternalValue(nextValue);
}
onChange?.(nextValue);
},
[onChange, value],
);
const emitHaptic = useCallback(async (nextSelected: boolean) => {
try {
if (nextSelected) {
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
return;
}
await Haptics.selectionAsync();
} catch {
// Haptics can fail on unsupported devices, the toggle should still work.
}
}, []);
const handleToggleGoing = useCallback(async () => {
if (disabled || loadingGoing) {
return;
}
const nextGoing = !resolvedValue.going;
const nextValue = {
...resolvedValue,
going: nextGoing,
};
await emitHaptic(nextGoing);
commitChange(nextValue);
onToggleGoing?.(nextGoing);
}, [commitChange, disabled, emitHaptic, loadingGoing, onToggleGoing, resolvedValue]);
const handleToggleInterested = useCallback(async () => {
if (disabled || loadingInterested) {
return;
}
const nextInterested = !resolvedValue.interested;
const nextValue = {
...resolvedValue,
interested: nextInterested,
};
await emitHaptic(nextInterested);
commitChange(nextValue);
onToggleInterested?.(nextInterested);
}, [commitChange, disabled, emitHaptic, loadingInterested, onToggleInterested, resolvedValue]);
const goingGlowStyle = useAnimatedStyle(() => ({
shadowOpacity: interpolate(goingProgress.value, [0, 1], [glow.primary.shadowOpacity ?? 0, 0]),
elevation: interpolate(goingProgress.value, [0, 1], [glow.primary.elevation ?? 0, 0]),
}));
const goingIdleLayerStyle = useAnimatedStyle(() => ({
opacity: interpolate(goingProgress.value, [0, 1], [1, 0]),
}));
const goingSelectedLayerStyle = useAnimatedStyle(() => ({
opacity: interpolate(goingProgress.value, [0, 1], [0, 1]),
}));
const interestedIdleLayerStyle = useAnimatedStyle(() => ({
opacity: interpolate(interestedProgress.value, [0, 1], [1, 0]),
}));
const interestedSelectedLayerStyle = useAnimatedStyle(() => ({
opacity: interpolate(interestedProgress.value, [0, 1], [0, 1]),
}));
const goingAccessibilityLabel = useMemo(
() => (resolvedValue.going ? 'Se desinscrire de la participation' : 'Participer a cet evenement'),
[resolvedValue.going],
);
const interestedAccessibilityLabel = useMemo(
() =>
resolvedValue.interested
? 'Retirer ce marqueur interesse'
: 'Marquer cet evenement comme interesse',
[resolvedValue.interested],
);
const buttonRadius = borderRadius.button;
return (
<View style={[styles.row, style]} testID={testID}>
<AnimatedView
style={[
styles.buttonShell,
{
borderRadius: buttonRadius,
shadowColor: glow.primary.shadowColor,
shadowOffset: glow.primary.shadowOffset,
shadowRadius: glow.primary.shadowRadius,
},
!disabled ? goingGlowStyle : null,
]}
>
<Pressable
onPress={handleToggleGoing}
disabled={disabled || loadingGoing}
accessibilityRole="button"
accessibilityState={{ selected: resolvedValue.going, disabled: disabled || loadingGoing, busy: loadingGoing }}
accessibilityLabel={goingAccessibilityLabel}
style={styles.pressable}
>
{disabled ? (
<View
style={[
styles.layer,
{
borderRadius: buttonRadius,
backgroundColor: colors.surfaceElevated,
borderColor: colors.border,
borderWidth: 1.5,
opacity: 0.45,
},
]}
>
<Check color={colors.textDisabled} size={ICON_SIZE} strokeWidth={2} />
<Text style={[typography.button, { color: colors.textDisabled }]}>Je participe</Text>
</View>
) : (
<>
<AnimatedView style={[styles.layer, { borderRadius: buttonRadius }, goingIdleLayerStyle]}>
<LinearGradient
colors={gradients.signature}
start={gradientDirection.start}
end={gradientDirection.end}
style={[styles.fill, { borderRadius: buttonRadius }]}
>
{loadingGoing ? (
<ActivityIndicator size={SPINNER_SIZE} color={colors.textPrimary} />
) : (
<>
<Check color={colors.textPrimary} size={ICON_SIZE} strokeWidth={2} />
<Text style={[typography.button, { color: colors.textPrimary }]}>Je participe</Text>
</>
)}
</LinearGradient>
</AnimatedView>
<AnimatedView style={[styles.layer, { borderRadius: buttonRadius }, goingSelectedLayerStyle]}>
<View
style={[
styles.fill,
{
borderRadius: buttonRadius,
backgroundColor: colors.successSoft,
borderWidth: 1.5,
borderColor: colors.successBorder,
},
]}
>
{loadingGoing ? (
<ActivityIndicator size={SPINNER_SIZE} color={colors.success} />
) : (
<>
<Check color={colors.success} size={ICON_SIZE} strokeWidth={2} />
<Text style={[typography.button, { color: colors.success }]}>Inscrit</Text>
</>
)}
</View>
</AnimatedView>
</>
)}
</Pressable>
</AnimatedView>
<View style={[styles.buttonShell, { borderRadius: buttonRadius }]}>
<Pressable
onPress={handleToggleInterested}
disabled={disabled || loadingInterested}
accessibilityRole="button"
accessibilityState={{ selected: resolvedValue.interested, disabled: disabled || loadingInterested, busy: loadingInterested }}
accessibilityLabel={interestedAccessibilityLabel}
style={styles.pressable}
>
{disabled ? (
<View
style={[
styles.layer,
{
borderRadius: buttonRadius,
backgroundColor: colors.surfaceElevated,
borderColor: colors.border,
borderWidth: 1.5,
opacity: 0.45,
},
]}
>
<Heart color={colors.textDisabled} size={ICON_SIZE} strokeWidth={1.7} />
<Text style={[typography.button, { color: colors.textDisabled }]}>Interesse</Text>
</View>
) : (
<>
<AnimatedView style={[styles.layer, { borderRadius: buttonRadius }, interestedIdleLayerStyle]}>
<View
style={[
styles.fill,
{
borderRadius: buttonRadius,
backgroundColor: colors.surfaceElevated,
borderWidth: 1.5,
borderColor: colors.border,
},
]}
>
{loadingInterested ? (
<ActivityIndicator size={SPINNER_SIZE} color={colors.textPrimary} />
) : (
<>
<Heart color={colors.textPrimary} size={ICON_SIZE} strokeWidth={1.7} fill="none" />
<Text style={[typography.button, { color: colors.textPrimary }]}>Interesse</Text>
</>
)}
</View>
</AnimatedView>
<AnimatedView style={[styles.layer, { borderRadius: buttonRadius }, interestedSelectedLayerStyle]}>
<View
style={[
styles.fill,
{
borderRadius: buttonRadius,
backgroundColor: colors.primarySoft,
borderWidth: 1.5,
borderColor: colors.primaryBorder,
},
]}
>
{loadingInterested ? (
<ActivityIndicator size={SPINNER_SIZE} color={colors.primary} />
) : (
<>
<Heart color={colors.primary} size={ICON_SIZE} strokeWidth={0} fill={colors.primary} />
<Text style={[typography.button, { color: colors.primary }]}>Interesse</Text>
</>
)}
</View>
</AnimatedView>
</>
)}
</Pressable>
</View>
</View>
);
}
export type { ParticipationSegmentProps, ParticipationValue };
export default ParticipationSegment;
const styles = StyleSheet.create({
row: {
flexDirection: 'row',
gap: 12,
alignItems: 'center',
},
buttonShell: {
flex: 1,
height: BUTTON_HEIGHT,
overflow: 'hidden',
},
pressable: {
flex: 1,
},
layer: {
...StyleSheet.absoluteFill,
},
fill: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
},
});