feat : ParticipationSegment
This commit is contained in:
372
src/components/ParticipationSegment.tsx
Normal file
372
src/components/ParticipationSegment.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user