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