import { useEffect, useMemo, useRef, useState, ReactNode, FC } from "react"; import { Pressable, StyleSheet, View, Dimensions, Text, Animated, Easing, } from "react-native"; // Icons are provided by installedApps; no direct icon imports here. import { DialIcon } from "./DialIcon"; import { gameUIColors, dialColors, safeGetItem, safeSetItem, useHintsDisabled, } from "@react-buoy/shared-ui"; import { DevToolsSettingsModal, type DevToolsSettings, useDevToolsSettings, } from "../DevToolsSettingsModal"; import type { InstalledApp, FloatingMenuActions, FloatingMenuState, } from "../types"; import { useAppHost } from "../AppHost"; import { OnboardingTooltip } from "./OnboardingTooltip"; const { width: SCREEN_WIDTH } = Dimensions.get("window"); const CIRCLE_SIZE = Math.min(SCREEN_WIDTH * 0.75, 320); // Max 320px for better fit const BUTTON_SIZE = 80; // Fixed button size const MAX_DIAL_SLOTS = 6; const ONBOARDING_STORAGE_KEY = "@react_buoy_settings_tooltip_shown"; export type IconType = { id?: string; // optional; used for special behaviors like wifi toggle name: string; icon: ReactNode; /** * Original icon component/function for dynamic rendering with hooks. * * ⚠️ IMPORTANT - DO NOT REMOVE THIS PROPERTY ⚠️ * This allows DialIcon to render the icon as a JSX component, enabling * React hooks to work (e.g., WiFi toggle subscribing to onlineManager). */ iconComponent?: (ctx: { slot?: string; size?: number; state?: FloatingMenuState; actions?: FloatingMenuActions }) => ReactNode; color: string; onPress: () => void; }; interface DialDevToolsProps { onClose?: () => void; onSettingsPress?: () => void; settings?: DevToolsSettings; autoOpenSettings?: boolean; apps: InstalledApp[]; // required now state?: FloatingMenuState; actions?: FloatingMenuActions; } export const DialDevTools: FC = ({ onClose, onSettingsPress, settings: externalSettings, autoOpenSettings = false, apps, state, actions, }) => { const [selectedIcon, setSelectedIcon] = useState(-1); const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false); const [showOnboardingTooltip, setShowOnboardingTooltip] = useState(false); const onboardingDismissedRef = useRef(false); // Track if onboarding was dismissed const hintsDisabled = useHintsDisabled(); const { settings: hookSettings, refreshSettings } = useDevToolsSettings(); const { open } = useAppHost(); // Initialize with external settings if provided, otherwise use hook settings const [localSettings, setLocalSettings] = useState( externalSettings || hookSettings ); // Always use localSettings (which can be updated by the modal) const settings = localSettings; // Update local settings when external settings change useEffect(() => { if (externalSettings) { setLocalSettings(externalSettings); } }, [externalSettings]); // Update local settings when hook settings change (if no external settings) useEffect(() => { if (!externalSettings) { setLocalSettings(hookSettings); } }, [hookSettings, externalSettings]); // Auto-open settings modal when prop is true useEffect(() => { if (autoOpenSettings && !isSettingsModalOpen) { setIsSettingsModalOpen(true); } }, [autoOpenSettings, isSettingsModalOpen]); // Check if we should show the onboarding tooltip useEffect(() => { // Skip onboarding if hints are disabled if (hintsDisabled) { return; } const checkOnboarding = async () => { try { const hasSeenTooltip = await safeGetItem(ONBOARDING_STORAGE_KEY); if (!hasSeenTooltip) { // Small delay to let the entrance animations play first setTimeout(() => { setShowOnboardingTooltip(true); }, 1200); } } catch (error) { // If there's an error reading storage, don't show the tooltip // to avoid annoying the user repeatedly } }; checkOnboarding(); }, [hintsDisabled]); // React Native Animated values const backdropOpacity = useRef(new Animated.Value(0)).current; const dialScale = useRef(new Animated.Value(0)).current; const dialRotation = useRef(new Animated.Value(0)).current; const centerButtonScale = useRef(new Animated.Value(0)).current; const iconsProgress = useRef(new Animated.Value(0)).current; const glitchOffset = useRef(new Animated.Value(0)).current; const pulseScale = useRef(new Animated.Value(1)).current; const availableApps = useMemo( () => apps.map(({ id, name, slot, description }) => ({ id, name, slot, description, })), [apps] ); // Subtle animations const floatingAnim = useRef(new Animated.Value(0)).current; const breathingScale = useRef(new Animated.Value(1)).current; const circuitOpacity = useRef(new Animated.Value(0)).current; // Animation tracking refs const glitchIntervalRef = useRef | null>(null); const pulseAnimationRef = useRef(null); // Map data-driven apps to dial icons, inserting empty slots for disabled items const dialApps = apps.filter((a) => (a.slot ?? "both") !== "row"); const isDialEnabled = (id: string) => { if (!settings) return false; return settings.dialTools[id] ?? false; }; const createEmptySlot = (slotIndex: number | string): IconType => ({ id: `empty-${slotIndex}`, name: `empty-${slotIndex}`, icon: null, color: "transparent", onPress: () => {}, }); const enabledIcons: IconType[] = []; for (const app of dialApps) { if (!isDialEnabled(app.id)) { continue; } if (enabledIcons.length >= MAX_DIAL_SLOTS) { break; } enabledIcons.push({ id: app.id, name: app.name, // Pass both the pre-rendered icon (for non-function icons) and the component (for dynamic rendering) icon: typeof app.icon === "function" ? null // Will be rendered dynamically by DialIcon : app.icon, // Cast to the expected type - the function signature is compatible at runtime iconComponent: typeof app.icon === "function" ? (app.icon as IconType["iconComponent"]) : undefined, color: app.color ?? gameUIColors.primary, onPress: () => { // Call the app's onPress callback if provided, passing actions for toggle tools app?.onPress?.(actions); // Only open modal if not a toggle-only tool if (app.launchMode !== "toggle-only") { const resolvedIcon = typeof app.icon === "function" ? app.icon({ slot: "dial", size: 20 }) : app.icon; open({ id: app.id, title: app.name, component: app.component, props: app.props, launchMode: app.launchMode ?? "self-modal", singleton: app.singleton ?? true, icon: resolvedIcon, color: app.color, }); } // Close the dial onClose?.(); }, }); } if (__DEV__) { const totalEnabled = dialApps.filter((app) => isDialEnabled(app.id)).length; if (totalEnabled > MAX_DIAL_SLOTS) { // More tools enabled than can be shown - they will be hidden } } const icons: IconType[] = [...enabledIcons]; while (icons.length < MAX_DIAL_SLOTS) { icons.push(createEmptySlot(icons.length)); } // Initialize animations on mount useEffect(() => { // Entrance animation sequence Animated.timing(backdropOpacity, { toValue: 1, duration: 400, useNativeDriver: true, }).start(); Animated.spring(dialScale, { toValue: 1, damping: 15, stiffness: 150, mass: 1, useNativeDriver: true, }).start(); Animated.sequence([ Animated.timing(dialRotation, { toValue: 1, duration: 800, easing: Easing.out(Easing.cubic), useNativeDriver: true, }), Animated.timing(dialRotation, { toValue: 0, duration: 0, useNativeDriver: true, }), ]).start(); Animated.sequence([ Animated.delay(300), Animated.spring(centerButtonScale, { toValue: 1, damping: 10, stiffness: 200, useNativeDriver: true, }), ]).start(); Animated.sequence([ Animated.delay(500), Animated.timing(iconsProgress, { toValue: 1, duration: 600, easing: Easing.out(Easing.cubic), useNativeDriver: true, }), ]).start(); // Subtle glitch effect const glitchAnimation = () => { Animated.sequence([ Animated.timing(glitchOffset, { toValue: 2, duration: 50, useNativeDriver: true, }), Animated.timing(glitchOffset, { toValue: -2, duration: 50, useNativeDriver: true, }), Animated.timing(glitchOffset, { toValue: 0, duration: 50, useNativeDriver: true, }), ]).start(); }; glitchIntervalRef.current = setInterval(glitchAnimation, 3000); // Pulse animation const startPulse = () => { pulseAnimationRef.current = Animated.loop( Animated.sequence([ Animated.timing(pulseScale, { toValue: 1.02, duration: 1000, easing: Easing.inOut(Easing.ease), useNativeDriver: true, }), Animated.timing(pulseScale, { toValue: 0.98, duration: 1000, easing: Easing.inOut(Easing.ease), useNativeDriver: true, }), ]) ); pulseAnimationRef.current.start(); }; startPulse(); // Subtle floating animation for the dial Animated.loop( Animated.sequence([ Animated.timing(floatingAnim, { toValue: -8, duration: 3000, easing: Easing.inOut(Easing.ease), useNativeDriver: true, }), Animated.timing(floatingAnim, { toValue: 0, duration: 3000, easing: Easing.inOut(Easing.ease), useNativeDriver: true, }), ]) ).start(); // Gentle breathing effect for center button Animated.loop( Animated.sequence([ Animated.timing(breathingScale, { toValue: 1.05, duration: 2500, easing: Easing.inOut(Easing.ease), useNativeDriver: true, }), Animated.timing(breathingScale, { toValue: 0.98, duration: 2500, easing: Easing.inOut(Easing.ease), useNativeDriver: true, }), ]) ).start(); // Circuit traces fade in Animated.timing(circuitOpacity, { toValue: 1, duration: 1000, delay: 600, useNativeDriver: true, }).start(); return () => { if (glitchIntervalRef.current) { clearInterval(glitchIntervalRef.current); } if (pulseAnimationRef.current) { pulseAnimationRef.current.stop(); } }; }, []); const handleOnboardingDismiss = () => { // Mark as dismissed immediately in ref (synchronous, no re-render needed) onboardingDismissedRef.current = true; // Hide the tooltip setShowOnboardingTooltip(false); // Save to storage asynchronously in the background safeSetItem(ONBOARDING_STORAGE_KEY, "true").catch((error) => { // Silently fail - user already saw onboarding, just won't persist console.warn("Failed to save dial onboarding state:", error); }); }; const handleClose = () => { // Stop any ongoing animations first if (pulseAnimationRef.current) { pulseAnimationRef.current.stop(); } // Exit animation sequence - reverse order of entrance Animated.sequence([ // First animate icons back to center Animated.timing(iconsProgress, { toValue: 0, duration: 300, easing: Easing.in(Easing.cubic), useNativeDriver: true, }), // Then scale down center button and dial Animated.parallel([ Animated.timing(centerButtonScale, { toValue: 0, duration: 200, easing: Easing.in(Easing.cubic), useNativeDriver: true, }), Animated.timing(dialScale, { toValue: 0, duration: 250, easing: Easing.in(Easing.cubic), useNativeDriver: true, }), ]), // Finally fade out backdrop Animated.timing(backdropOpacity, { toValue: 0, duration: 200, useNativeDriver: true, }), ]).start(() => { // Use setTimeout to defer the state update to the next tick // This avoids the useInsertionEffect warning if (onClose) { setTimeout(() => { onClose(); }, 0); } }); }; const handleIconPress = (index: number) => { setSelectedIcon(index); // Pulse animation on selection Animated.sequence([ Animated.spring(centerButtonScale, { toValue: 0.9, damping: 15, stiffness: 500, useNativeDriver: true, }), Animated.spring(centerButtonScale, { toValue: 1, damping: 10, stiffness: 200, useNativeDriver: true, }), ]).start(); // Trigger action setTimeout(() => { icons[index].onPress(); // Only close if it's not the WiFi toggle (by id) if (icons[index].id !== "wifi") { handleClose(); } }, 50); }; // Animated styles const backdropAnimatedStyle = { opacity: backdropOpacity, }; const glitchAnimatedStyle = { transform: [{ translateX: glitchOffset }], }; const centerButtonAnimatedStyle = { transform: [ { scale: Animated.multiply(centerButtonScale, breathingScale), }, ], }; const pulseAnimatedStyle = { transform: [{ scale: selectedIcon >= 0 ? 1 : pulseScale }], }; return ( {/* Dark overlay backdrop */} {/* Cyberpunk dial background with glitch */} {/* Gradient background using layered Views */} {/* Matrix grid pattern */} {Array.from({ length: 6 }).map((_, i) => ( ))} {/* Icon items */} {icons.map((icon, i) => ( ))} {/* Center button */} { if (isSettingsModalOpen) { // Close settings modal setIsSettingsModalOpen(false); } else { // Open internal settings modal setIsSettingsModalOpen(true); // Dismiss onboarding tooltip when user opens settings if ( showOnboardingTooltip && !onboardingDismissedRef.current ) { handleOnboardingDismiss(); } // Also call external handler if provided if (onSettingsPress) { onSettingsPress(); } } }} style={styles.buttonPressable} > {isSettingsModalOpen ? ( <> CLOSE SETTINGS ) : ( <> BUOY DEV TOOLS )} {/* Settings Modal - Part of dial component for proper z-index */} { setIsSettingsModalOpen(false); refreshSettings(); // Refresh from storage }} onSettingsChange={(newSettings) => { // Immediately update local settings for instant feedback setLocalSettings(newSettings); }} availableApps={availableApps} /> {/* Onboarding Tooltip - Shows on first use */} ); }; const styles = StyleSheet.create({ container: { ...StyleSheet.absoluteFillObject, zIndex: 9999, }, backdrop: { ...StyleSheet.absoluteFillObject, backgroundColor: "rgba(0, 0, 0, 0.85)", // Darker overlay for better contrast without games }, parent: { width: CIRCLE_SIZE, height: CIRCLE_SIZE, alignItems: "center", justifyContent: "center", }, circle: { width: CIRCLE_SIZE, height: CIRCLE_SIZE, borderRadius: CIRCLE_SIZE / 2, position: "absolute", backgroundColor: "transparent", borderWidth: 1, borderColor: dialColors.dialBorder, shadowColor: dialColors.dialShadow, shadowOffset: { width: 0, height: 0 }, shadowOpacity: 0.5, shadowRadius: 20, elevation: 10, }, gradientBackground: { width: "100%", height: "100%", borderRadius: CIRCLE_SIZE / 2, position: "relative", backgroundColor: dialColors.dialBackground, overflow: "hidden", }, gradientLayer1: { ...StyleSheet.absoluteFillObject, backgroundColor: dialColors.dialGradient1, opacity: 0.6, borderRadius: CIRCLE_SIZE / 2, }, gradientLayer2: { ...StyleSheet.absoluteFillObject, backgroundColor: dialColors.dialGradient2, opacity: 0.4, top: "30%", left: "30%", borderRadius: CIRCLE_SIZE / 2, }, gradientLayer3: { ...StyleSheet.absoluteFillObject, backgroundColor: dialColors.dialGradient3, opacity: 0.3, top: "50%", left: "50%", borderRadius: CIRCLE_SIZE / 2, }, gridPattern: { ...StyleSheet.absoluteFillObject, alignItems: "center", justifyContent: "center", }, gridLine: { position: "absolute", width: CIRCLE_SIZE, height: 1, backgroundColor: dialColors.dialGridLine, }, buttonContainer: { zIndex: 1, backgroundColor: "transparent", alignItems: "center", justifyContent: "center", position: "absolute", width: BUTTON_SIZE * 1.5, height: BUTTON_SIZE * 1.5, borderRadius: BUTTON_SIZE, }, buttonGradient: { width: "100%", height: "100%", borderRadius: BUTTON_SIZE, alignItems: "center", justifyContent: "center", padding: 4, backgroundColor: dialColors.dialBackground, position: "relative", overflow: "hidden", }, buttonGradientLayer1: { ...StyleSheet.absoluteFillObject, backgroundColor: dialColors.dialGradient1, opacity: 0.5, borderRadius: BUTTON_SIZE, }, buttonGradientLayer2: { ...StyleSheet.absoluteFillObject, backgroundColor: dialColors.dialGradient2, opacity: 0.3, top: "20%", left: "20%", borderRadius: BUTTON_SIZE, }, buttonGradientLayer3: { ...StyleSheet.absoluteFillObject, backgroundColor: dialColors.dialGradient3, opacity: 0.2, top: "40%", left: "40%", borderRadius: BUTTON_SIZE, }, buttonBorder: { backgroundColor: dialColors.dialGridLine, alignItems: "center", justifyContent: "center", width: BUTTON_SIZE * 1.2, height: BUTTON_SIZE * 1.2, borderRadius: BUTTON_SIZE * 0.6, borderWidth: 2, borderColor: dialColors.dialBorder, }, button: { width: BUTTON_SIZE, height: BUTTON_SIZE, borderRadius: BUTTON_SIZE / 2, justifyContent: "center", alignItems: "center", position: "relative", overflow: "hidden", }, buttonPressable: { width: "100%", height: "100%", justifyContent: "center", alignItems: "center", }, centerText: { color: gameUIColors.primary, fontSize: 10, fontWeight: "900", fontFamily: "monospace", letterSpacing: 1, textAlign: "center", textTransform: "uppercase", textShadowColor: gameUIColors.info, textShadowOffset: { width: 0, height: 0 }, textShadowRadius: 4, }, closeTextTop: { marginBottom: -2, }, closeTextBottom: { marginTop: -2, }, });