import React, { useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { FlatList, Platform, StyleSheet, View, useWindowDimensions, } from "react-native"; import PersonaCard from "../cards/PersonaCard/PersonaCard"; import { ai } from "../../assets"; import { getCreationStageStates } from "../../utils/projectStages"; const STAGE_CARD_CONTENT = [ { key: "songwriter", title: "Nathalie", description: "Let’s write lyrics together !", image: ai.leftIcon, }, { key: "beatmaker", title: "Theo", description: "Come back, when you'll have lyrics!", image: ai.rightIcon, }, { key: "designer", title: "Bena", description: "Come back when you want to generate cover!", image: ai.leftIcon, }, { key: "director", title: "John", description: "Come back when your audio is ready!", image: ai.rightIcon, }, ]; const FeatureCarousel = ({ style, selectedProject, stageStates: stageStatesProp, activeIndex, onActiveIndexChange, }) => { const stageStates = useMemo(() => { if (stageStatesProp) { return stageStatesProp; } return getCreationStageStates(selectedProject); }, [stageStatesProp, selectedProject]); const carouselItems = useMemo( () => STAGE_CARD_CONTENT.map((item, index) => ({ ...item, isLocked: stageStates[index]?.isLocked ?? true, })), [stageStates], ); const { height: windowHeight } = useWindowDimensions(); const isWeb = Platform.OS === "web"; const [viewportHeight, setViewportHeight] = useState(() => Math.max(windowHeight, 1), ); const updateSnapHeight = useCallback((height) => { if (!height || Number.isNaN(height)) { return; } setViewportHeight((prev) => { if (prev == null || Math.abs(prev - height) > 0.5) { return height; } return prev; }); }, []); useEffect(() => { updateSnapHeight(Math.max(windowHeight, 1)); }, [updateSnapHeight, windowHeight]); const itemHeight = Math.max(viewportHeight, 1); const listRef = useRef(null); const pendingScrollRef = useRef(false); const alignTimeoutRef = useRef(null); const activeIndexRef = useRef( typeof activeIndex === "number" ? activeIndex : 0, ); const onActiveIndexChangeRef = useRef(onActiveIndexChange); useEffect(() => { onActiveIndexChangeRef.current = onActiveIndexChange; }, [onActiveIndexChange]); useEffect(() => { if (typeof activeIndex === "number") { activeIndexRef.current = activeIndex; } }, [activeIndex]); const clampIndex = useCallback( (index) => { if (!carouselItems.length) { return 0; } if (index < 0) { return 0; } if (index >= carouselItems.length) { return carouselItems.length - 1; } return index; }, [carouselItems.length], ); const clearPendingAlignment = useCallback(() => { if (alignTimeoutRef.current != null) { clearTimeout(alignTimeoutRef.current); alignTimeoutRef.current = null; } }, []); useEffect(() => () => clearPendingAlignment(), [clearPendingAlignment]); const scrollToIndex = useCallback( (index, animated = true, heightOverride) => { const ref = listRef.current; if (!ref) { return; } const clamped = clampIndex(index); const height = heightOverride && heightOverride > 0 ? heightOverride : itemHeight; if (isWeb) { if (!height) { return; } updateSnapHeight(height); try { ref.scrollToOffset({ offset: clamped * height, animated: false }); } catch (_error) { // Ignore scroll errors when list is not ready yet. } activeIndexRef.current = clamped; return; } try { pendingScrollRef.current = !!animated; ref.scrollToIndex({ index: clamped, animated }); activeIndexRef.current = clamped; } catch (_error) { pendingScrollRef.current = false; } }, [clampIndex, isWeb, itemHeight, updateSnapHeight], ); useEffect(() => { if ( listRef.current == null || typeof activeIndex !== "number" || activeIndex < 0 || activeIndex >= carouselItems.length || (isWeb && !itemHeight) ) { return; } scrollToIndex(activeIndex); }, [activeIndex, carouselItems.length, isWeb, itemHeight, scrollToIndex]); useEffect(() => { if (listRef.current == null || (isWeb && !itemHeight)) { return; } scrollToIndex(activeIndexRef.current, false); }, [carouselItems.length, isWeb, itemHeight, scrollToIndex]); const alignToOffset = useCallback( (offset, layoutHeight) => { const height = layoutHeight && layoutHeight > 0 ? layoutHeight : itemHeight; if (!height) { return; } updateSnapHeight(height); const nextIndex = clampIndex(Math.round(offset / height)); const hasChanged = nextIndex !== activeIndexRef.current; if (hasChanged) { activeIndexRef.current = nextIndex; const callback = onActiveIndexChangeRef.current; if (callback) { callback(nextIndex); } } if (isWeb || hasChanged) { scrollToIndex(nextIndex, !isWeb, height); } }, [clampIndex, isWeb, itemHeight, scrollToIndex, updateSnapHeight], ); const handleScrollEnd = useCallback( (event) => { clearPendingAlignment(); const offsetY = event?.nativeEvent?.contentOffset?.y ?? 0; const layoutHeight = event?.nativeEvent?.layoutMeasurement?.height; alignToOffset(offsetY, layoutHeight); }, [alignToOffset, clearPendingAlignment], ); const handleScroll = useCallback( (event) => { if (!isWeb) { return; } const offsetY = event?.nativeEvent?.contentOffset?.y ?? 0; const layoutHeight = event?.nativeEvent?.layoutMeasurement?.height; clearPendingAlignment(); alignTimeoutRef.current = setTimeout(() => { alignToOffset(offsetY, layoutHeight); alignTimeoutRef.current = null; }, 80); }, [alignToOffset, clearPendingAlignment, isWeb], ); const handleLayout = useCallback( (event) => { const layoutHeight = event?.nativeEvent?.layout?.height ?? 0; if (layoutHeight > 0) { updateSnapHeight(layoutHeight); } }, [updateSnapHeight], ); const keyExtractor = useCallback((item) => item.key, []); const renderItem = useCallback( ({ item, index }) => ( ), [itemHeight], ); const getItemLayout = useCallback( (_data, index) => ({ length: itemHeight, offset: itemHeight * index, index, }), [itemHeight], ); const viewabilityConfig = useRef({ viewAreaCoveragePercentThreshold: 60 }); const handleViewableItemsChangedRef = useRef(); if (!handleViewableItemsChangedRef.current) { handleViewableItemsChangedRef.current = ({ viewableItems }) => { if (!viewableItems?.length) return; const firstVisible = viewableItems.find((item) => item?.isViewable); if (!firstVisible || firstVisible.index == null) return; if (pendingScrollRef.current) { if (firstVisible.index === activeIndexRef.current) { pendingScrollRef.current = false; } return; } const callback = onActiveIndexChangeRef.current; if (callback && firstVisible.index !== activeIndexRef.current) { callback(firstVisible.index); } }; } const snapOffsets = useMemo(() => { if (!itemHeight || !isWeb) { return undefined; } return carouselItems.map((_, index) => index * itemHeight); }, [carouselItems.length, isWeb, itemHeight]); return ( ); }; export default FeatureCarousel; const styles = StyleSheet.create({ container: { flex: 1, width: "100%", }, list: { flex: 1, }, });