diff --git a/package-lock.json b/package-lock.json
index 057a867..521deb8 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -24,6 +24,7 @@
"expo-device": "~56.0.4",
"expo-font": "~56.0.7",
"expo-glass-effect": "~56.0.4",
+ "expo-haptics": "~56.0.3",
"expo-image": "~56.0.12",
"expo-linear-gradient": "~56.0.4",
"expo-linking": "~56.0.12",
@@ -4876,6 +4877,15 @@
"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": {
"version": "56.0.12",
"resolved": "https://registry.npmjs.org/expo-image/-/expo-image-56.0.12.tgz",
diff --git a/package.json b/package.json
index cbadd88..9d17deb 100644
--- a/package.json
+++ b/package.json
@@ -19,6 +19,7 @@
"expo-device": "~56.0.4",
"expo-font": "~56.0.7",
"expo-glass-effect": "~56.0.4",
+ "expo-haptics": "~56.0.3",
"expo-image": "~56.0.12",
"expo-linear-gradient": "~56.0.4",
"expo-linking": "~56.0.12",
diff --git a/src/app/index.tsx b/src/app/index.tsx
index 5b9cd08..4bfa9a4 100644
--- a/src/app/index.tsx
+++ b/src/app/index.tsx
@@ -4,6 +4,7 @@ import { ScrollView, StyleSheet, Text, View } from 'react-native';
import { Badge } from '@/components/Badge';
import { Button } from '@/components/Button';
+import { ParticipationSegment } from '@/components/ParticipationSegment';
import { ThemeChip } from '@/components/ThemeChip';
import { useTheme } from '@/theme/useTheme';
@@ -125,6 +126,14 @@ export default function Index() {
+
+ PARTICIPATION
+
+
+
+
+
+
BADGES
diff --git a/src/components/ParticipationSegment.tsx b/src/components/ParticipationSegment.tsx
new file mode 100644
index 0000000..f866702
--- /dev/null
+++ b/src/components/ParticipationSegment.tsx
@@ -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;
+ onChange?: (nextValue: ParticipationValue) => void;
+ onToggleGoing?: (nextGoing: boolean) => void;
+ onToggleInterested?: (nextInterested: boolean) => void;
+ disabled?: boolean;
+ loadingGoing?: boolean;
+ loadingInterested?: boolean;
+ style?: StyleProp;
+ 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({
+ 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 (
+
+
+
+ {disabled ? (
+
+
+ Je participe
+
+ ) : (
+ <>
+
+
+ {loadingGoing ? (
+
+ ) : (
+ <>
+
+ Je participe
+ >
+ )}
+
+
+
+
+
+ {loadingGoing ? (
+
+ ) : (
+ <>
+
+ Inscrit
+ >
+ )}
+
+
+ >
+ )}
+
+
+
+
+
+ {disabled ? (
+
+
+ Interesse
+
+ ) : (
+ <>
+
+
+ {loadingInterested ? (
+
+ ) : (
+ <>
+
+ Interesse
+ >
+ )}
+
+
+
+
+
+ {loadingInterested ? (
+
+ ) : (
+ <>
+
+ Interesse
+ >
+ )}
+
+
+ >
+ )}
+
+
+
+ );
+}
+
+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,
+ },
+});