feat : ParticipationSegment
This commit is contained in:
10
package-lock.json
generated
10
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
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