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

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,
},
});