├── .env ├── .gitignore ├── README.md ├── app.json ├── app ├── (tabs) │ ├── _layout.tsx │ ├── explore.tsx │ └── index.tsx ├── +not-found.tsx ├── _hooks │ └── usePokemon.ts ├── _layout.tsx └── dev-tools-bubble │ ├── DevTools.tsx │ ├── DevToolsBubble.tsx │ ├── _components │ ├── _hooks │ │ ├── useAllMutations.ts │ │ ├── useAllQueries.ts │ │ └── useQueryStatusCounts.ts │ ├── _util │ │ ├── actions │ │ │ ├── dataSyncFromServer.ts │ │ │ ├── deleteItem.ts │ │ │ ├── invalidate.ts │ │ │ ├── refetch.ts │ │ │ ├── remove.ts │ │ │ ├── reset.ts │ │ │ ├── triggerError.ts │ │ │ └── triggerLoading.ts │ │ ├── deleteNestedDataByPath.ts │ │ ├── getQueryStatusColor.ts │ │ ├── getQueryStatusLabel.ts │ │ ├── mutationStatusToColorClass.ts │ │ ├── statusTobgColorClass.ts │ │ └── updateNestedDataByPath.ts │ └── devtools │ │ ├── ActionButton.tsx │ │ ├── ClearCacheButton.tsx │ │ ├── DevToolsHeader.tsx │ │ ├── Explorer.tsx │ │ ├── MutationButton.tsx │ │ ├── MutationDetails.tsx │ │ ├── MutationDetailsChips.tsx │ │ ├── MutationInformation.tsx │ │ ├── MutationsList.tsx │ │ ├── NetworkToggleButton.tsx │ │ ├── QueriesList.tsx │ │ ├── QueryActions.tsx │ │ ├── QueryDetails.tsx │ │ ├── QueryDetailsChip.tsx │ │ ├── QueryInformation.tsx │ │ ├── QueryRow.tsx │ │ ├── QueryStatus.tsx │ │ ├── QueryStatusCount.tsx │ │ ├── displayValue.ts │ │ └── svgs.tsx │ ├── context │ └── CopyContext.tsx │ └── index.ts ├── assets ├── fonts │ └── SpaceMono-Regular.ttf └── images │ ├── adaptive-icon.png │ ├── favicon.png │ ├── icon.png │ ├── partial-react-logo.png │ ├── react-logo.png │ ├── react-logo@2x.png │ ├── react-logo@3x.png │ └── splash-icon.png ├── components ├── Collapsible.tsx ├── EnvDemo.tsx ├── ExternalLink.tsx ├── HapticTab.tsx ├── HelloWave.tsx ├── ParallaxScrollView.tsx ├── StorageDemo.tsx ├── ThemedText.tsx ├── ThemedView.tsx ├── __tests__ │ ├── ThemedText-test.tsx │ └── __snapshots__ │ │ └── ThemedText-test.tsx.snap └── ui │ ├── IconSymbol.ios.tsx │ ├── IconSymbol.tsx │ ├── TabBarBackground.ios.tsx │ └── TabBarBackground.tsx ├── constants └── Colors.ts ├── hooks ├── useColorScheme.ts ├── useColorScheme.web.ts └── useThemeColor.ts ├── package-lock.json ├── package.json ├── scripts └── reset-project.js ├── storage └── mmkv.ts └── tsconfig.json /.env: -------------------------------------------------------------------------------- 1 | # Mock Environment Variables for Testing 2 | EXPO_PUBLIC_API_URL=https://api.example.com 3 | EXPO_PUBLIC_APP_NAME=RN DevTools Example 4 | EXPO_PUBLIC_VERSION=1.0.0 5 | EXPO_PUBLIC_ENVIRONMENT=development 6 | EXPO_PUBLIC_FEATURE_FLAG_STORAGE_DEMO=true 7 | NODE_ENV=development 8 | EXPO_PUBLIC_DEBUG_MODE=true 9 | EXPO_PUBLIC_ANALYTICS_ENABLED=false 10 | SECRET_KEY=demo-secret-key-not-for-production 11 | DATABASE_URL=sqlite://demo.db 12 | API_SECRET=demo-api-secret 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files 2 | 3 | # dependencies 4 | node_modules/ 5 | 6 | # Expo 7 | .expo/ 8 | dist/ 9 | web-build/ 10 | expo-env.d.ts 11 | 12 | # Native 13 | *.orig.* 14 | *.jks 15 | *.p8 16 | *.p12 17 | *.key 18 | *.mobileprovision 19 | 20 | # Metro 21 | .metro-health-check* 22 | 23 | # debug 24 | npm-debug.* 25 | yarn-debug.* 26 | yarn-error.* 27 | 28 | # macOS 29 | .DS_Store 30 | *.pem 31 | 32 | # local env files (commented out for example repo) 33 | # .env*.local 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | 38 | # Android/iOS build directories 39 | android/ 40 | ios/ 41 | 42 | app-example 43 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Native DevTools Example 2 | 3 | Enhanced developer tools for React Native applications, supporting React Query DevTools and device storage monitoring with a beautiful native interface. 4 | 5 | ![ios pokemon](https://github.com/user-attachments/assets/25ffb38c-2e41-4aa9-a3c7-6f74383a75fc) 6 | 7 | https://github.com/user-attachments/assets/fce3cba3-b30a-409a-8f8f-db2bd28579be 8 | 9 | 10 | https://github.com/user-attachments/assets/24183264-fff2-4e7d-86f7-2775362cf485 11 | 12 | 13 | ## ✨ Features 14 | 15 | - 🔄 Real-time React Query state monitoring 16 | - 💾 **Device storage monitoring with CRUD operations** - MMKV, AsyncStorage, and SecureStorage 17 | - 🌐 **Environment variables monitoring** - View and track public environment variables 18 | - 🎨 Beautiful native macOS interface 19 | - 🚀 Automatic connection to React apps 20 | - 📊 Query status visualization 21 | - 🔌 Socket.IO integration for reliable communication 22 | - ⚡️ Simple setup with NPM package 23 | - 📱 Works with **any React-based platform**: React Native, React Web, Next.js, Expo, tvOS, VR, etc. 24 | - 🛑 Zero-config production safety - automatically disabled in production builds 25 | 26 | ## 🚀 Quick Start 27 | 28 | 1. Clone this repository 29 | 2. Install dependencies: `npm install` 30 | 3. Start the development server: `npm start` 31 | 4. Download and launch the [React Native DevTools](https://github.com/LovesWorking/rn-better-dev-tools) desktop app 32 | 5. The app will automatically connect and sync React Query state, storage, and environment variables 33 | 34 | ## 💾 Storage Demo 35 | 36 | This example app demonstrates real-time storage monitoring with: 37 | 38 | - **Mock MMKV Storage**: High-performance key-value storage simulation (using AsyncStorage for Expo Go compatibility) 39 | - **AsyncStorage**: React Native's standard async storage 40 | - **SecureStore**: Secure storage for sensitive data (Expo SecureStore) 41 | 42 | ### Features Demonstrated 43 | 44 | - **Real-time monitoring**: See storage changes as they happen in DevTools 45 | - **CRUD operations**: Create, read, update, and delete storage entries directly from the app 46 | - **React Query integration**: Access storage data via queries like `['#storage', 'mmkv', 'keyName']` 47 | - **Type-safe operations**: Automatic serialization/deserialization of complex data types 48 | - **Expo Go compatibility**: Mock MMKV implementation that works with Expo Go 49 | 50 | > **Note**: This demo uses a mock MMKV implementation for Expo Go compatibility. In a real development build or production app, you would use the actual [react-native-mmkv](https://github.com/mrousavy/react-native-mmkv) package for better performance. 51 | 52 | ## 🌐 Environment Variables Demo 53 | 54 | The app showcases environment variable monitoring with: 55 | 56 | - **Public variables**: `EXPO_PUBLIC_*` variables visible to the client 57 | - **Private variables**: Server-side only variables for development 58 | - **Real-time sync**: Environment variables are automatically synced with DevTools 59 | - **Visual indicators**: Clear distinction between public and private variables 60 | 61 | ### Example Environment Variables 62 | 63 | ```bash 64 | # Public variables (visible in client) 65 | EXPO_PUBLIC_API_URL=https://api.example.com 66 | EXPO_PUBLIC_APP_NAME=RN DevTools Example 67 | EXPO_PUBLIC_DEBUG_MODE=true 68 | 69 | # Private variables (development only) 70 | NODE_ENV=development 71 | SECRET_KEY=demo-secret-key-not-for-production 72 | DATABASE_URL=sqlite://demo.db 73 | ``` 74 | 75 | ## 📱 Related Projects 76 | 77 | - **Desktop App**: [React Native DevTools](https://github.com/LovesWorking/rn-better-dev-tools) 78 | - **NPM Package**: [react-query-external-sync](https://www.npmjs.com/package/react-query-external-sync) 79 | - **Expo Plugin**: [tanstack-query-dev-tools-expo-plugin](https://github.com/LovesWorking/tanstack-query-dev-tools-expo-plugin) 80 | 81 | ## 🛠️ Technical Implementation 82 | 83 | This example uses: 84 | 85 | - **React Query v5** for state management 86 | - **Expo Router** for navigation 87 | - **Mock MMKV** for high-performance storage simulation (Expo Go compatible) 88 | - **AsyncStorage** for standard React Native storage 89 | - **Expo SecureStore** for secure data storage 90 | - **Socket.IO** for real-time communication with DevTools 91 | 92 | ## 📄 License 93 | 94 | MIT 95 | 96 | --- 97 | 98 | Made with ❤️ by [LovesWorking](https://github.com/LovesWorking) 99 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo": { 3 | "name": "rn-dev-tools-exmaple", 4 | "slug": "rn-dev-tools-exmaple", 5 | "version": "1.0.0", 6 | "orientation": "portrait", 7 | "icon": "./assets/images/icon.png", 8 | "scheme": "myapp", 9 | "userInterfaceStyle": "automatic", 10 | "newArchEnabled": true, 11 | "ios": { 12 | "supportsTablet": true, 13 | "bundleIdentifier": "com.lovesworking.rndevtoolsexmaple" 14 | }, 15 | "android": { 16 | "adaptiveIcon": { 17 | "foregroundImage": "./assets/images/adaptive-icon.png", 18 | "backgroundColor": "#ffffff" 19 | }, 20 | "package": "com.lovesworking.rndevtoolsexmaple" 21 | }, 22 | "web": { 23 | "bundler": "metro", 24 | "output": "static", 25 | "favicon": "./assets/images/favicon.png" 26 | }, 27 | "plugins": [ 28 | "expo-router", 29 | [ 30 | "expo-splash-screen", 31 | { 32 | "image": "./assets/images/splash-icon.png", 33 | "imageWidth": 200, 34 | "resizeMode": "contain", 35 | "backgroundColor": "#ffffff" 36 | } 37 | ], 38 | "expo-font", 39 | "expo-web-browser" 40 | ], 41 | "experiments": { 42 | "typedRoutes": true 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /app/(tabs)/_layout.tsx: -------------------------------------------------------------------------------- 1 | import { Tabs } from "expo-router"; 2 | import React from "react"; 3 | import { Platform, Animated, View } from "react-native"; 4 | 5 | import { HapticTab } from "@/components/HapticTab"; 6 | import { IconSymbol } from "@/components/ui/IconSymbol"; 7 | import { Colors } from "@/constants/Colors"; 8 | import { useColorScheme } from "@/hooks/useColorScheme"; 9 | import { MaterialCommunityIcons } from "@expo/vector-icons"; 10 | 11 | // Custom tab bar background that adapts to Pokémon colors 12 | const DynamicTabBarBackground = ({ color }: { color: string }) => { 13 | return ( 14 | 26 | ); 27 | }; 28 | 29 | // Create a store to share Pokémon data across components 30 | // Add this in a new file: store/pokemonStore.ts 31 | // For now we'll define it here so you can copy it to the right place 32 | const createPokemonStore = () => { 33 | let listeners: (() => void)[] = []; 34 | let currentPokemonType: string | null = null; 35 | 36 | return { 37 | setPokemonType: (type: string | null) => { 38 | currentPokemonType = type; 39 | listeners.forEach((listener) => listener()); 40 | }, 41 | getPokemonType: () => currentPokemonType, 42 | subscribe: (listener: () => void) => { 43 | listeners.push(listener); 44 | return () => { 45 | listeners = listeners.filter((l) => l !== listener); 46 | }; 47 | }, 48 | }; 49 | }; 50 | 51 | export const pokemonStore = createPokemonStore(); 52 | export const usePokemonStore = () => { 53 | const [pokemonType, setPokemonType] = React.useState( 54 | pokemonStore.getPokemonType() 55 | ); 56 | 57 | React.useEffect(() => { 58 | return pokemonStore.subscribe(() => { 59 | setPokemonType(pokemonStore.getPokemonType()); 60 | }); 61 | }, []); 62 | 63 | return pokemonType; 64 | }; 65 | 66 | export default function TabLayout() { 67 | const colorScheme = useColorScheme(); 68 | const isDark = colorScheme === "dark"; 69 | const pokemonType = usePokemonStore(); 70 | 71 | // Get color based on Pokemon type 72 | const getTypeColor = (type: string | null) => { 73 | if (!type) return isDark ? "#1D3D47" : "#A1CEDC"; 74 | 75 | const typeColors: Record = { 76 | normal: "#A8A878", 77 | fire: "#F08030", 78 | water: "#6890F0", 79 | electric: "#F8D030", 80 | grass: "#78C850", 81 | ice: "#98D8D8", 82 | fighting: "#C03028", 83 | poison: "#A040A0", 84 | ground: "#E0C068", 85 | flying: "#A890F0", 86 | psychic: "#F85888", 87 | bug: "#A8B820", 88 | rock: "#B8A038", 89 | ghost: "#705898", 90 | dragon: "#7038F8", 91 | dark: "#705848", 92 | steel: "#B8B8D0", 93 | fairy: "#EE99AC", 94 | }; 95 | return typeColors[type] || "#68A090"; 96 | }; 97 | 98 | // Calculate if text should be black or white based on background brightness 99 | const getContrastColor = (hexColor: string) => { 100 | // Convert hex to RGB 101 | const r = parseInt(hexColor.substr(1, 2), 16); 102 | const g = parseInt(hexColor.substr(3, 2), 16); 103 | const b = parseInt(hexColor.substr(5, 2), 16); 104 | 105 | // Calculate brightness (YIQ equation) 106 | const brightness = (r * 299 + g * 587 + b * 114) / 1000; 107 | 108 | // Return black or white based on brightness 109 | return brightness > 128 ? "#000000" : "#FFFFFF"; 110 | }; 111 | 112 | // Get the colors based on current Pokémon type 113 | const tabBarColor = getTypeColor(pokemonType); 114 | const iconColor = getContrastColor(tabBarColor); 115 | 116 | return ( 117 | , 124 | tabBarStyle: { 125 | position: "absolute", 126 | elevation: 0, 127 | height: 60, 128 | borderTopWidth: 0, 129 | backgroundColor: "transparent", 130 | }, 131 | tabBarLabelStyle: { 132 | fontWeight: "bold", 133 | fontSize: 12, 134 | }, 135 | }} 136 | > 137 | ( 142 | 143 | ), 144 | }} 145 | /> 146 | 147 | ); 148 | } 149 | -------------------------------------------------------------------------------- /app/(tabs)/explore.tsx: -------------------------------------------------------------------------------- 1 | import { StyleSheet, Image, Platform } from 'react-native'; 2 | 3 | import { Collapsible } from '@/components/Collapsible'; 4 | import { ExternalLink } from '@/components/ExternalLink'; 5 | import ParallaxScrollView from '@/components/ParallaxScrollView'; 6 | import { ThemedText } from '@/components/ThemedText'; 7 | import { ThemedView } from '@/components/ThemedView'; 8 | import { IconSymbol } from '@/components/ui/IconSymbol'; 9 | 10 | export default function TabTwoScreen() { 11 | return ( 12 | 21 | }> 22 | 23 | Explore 24 | 25 | This app includes example code to help you get started. 26 | 27 | 28 | This app has two screens:{' '} 29 | app/(tabs)/index.tsx and{' '} 30 | app/(tabs)/explore.tsx 31 | 32 | 33 | The layout file in app/(tabs)/_layout.tsx{' '} 34 | sets up the tab navigator. 35 | 36 | 37 | Learn more 38 | 39 | 40 | 41 | 42 | You can open this project on Android, iOS, and the web. To open the web version, press{' '} 43 | w in the terminal running this project. 44 | 45 | 46 | 47 | 48 | For static images, you can use the @2x and{' '} 49 | @3x suffixes to provide files for 50 | different screen densities 51 | 52 | 53 | 54 | Learn more 55 | 56 | 57 | 58 | 59 | Open app/_layout.tsx to see how to load{' '} 60 | 61 | custom fonts such as this one. 62 | 63 | 64 | 65 | Learn more 66 | 67 | 68 | 69 | 70 | This template has light and dark mode support. The{' '} 71 | useColorScheme() hook lets you inspect 72 | what the user's current color scheme is, and so you can adjust UI colors accordingly. 73 | 74 | 75 | Learn more 76 | 77 | 78 | 79 | 80 | This template includes an example of an animated component. The{' '} 81 | components/HelloWave.tsx component uses 82 | the powerful react-native-reanimated{' '} 83 | library to create a waving hand animation. 84 | 85 | {Platform.select({ 86 | ios: ( 87 | 88 | The components/ParallaxScrollView.tsx{' '} 89 | component provides a parallax effect for the header image. 90 | 91 | ), 92 | })} 93 | 94 | 95 | ); 96 | } 97 | 98 | const styles = StyleSheet.create({ 99 | headerImage: { 100 | color: '#808080', 101 | bottom: -90, 102 | left: -35, 103 | position: 'absolute', 104 | }, 105 | titleContainer: { 106 | flexDirection: 'row', 107 | gap: 8, 108 | }, 109 | }); 110 | -------------------------------------------------------------------------------- /app/(tabs)/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Image, 3 | StyleSheet, 4 | TouchableOpacity, 5 | TextInput, 6 | ActivityIndicator, 7 | } from "react-native"; 8 | import ParallaxScrollView from "@/components/ParallaxScrollView"; 9 | import { ThemedText } from "@/components/ThemedText"; 10 | import { ThemedView } from "@/components/ThemedView"; 11 | import { useState, useEffect } from "react"; 12 | import { usePokemon } from "../_hooks/usePokemon"; 13 | import { Ionicons } from "@expo/vector-icons"; 14 | import { useColorScheme } from "react-native"; 15 | import { pokemonStore } from "./_layout"; 16 | import { StorageDemo } from "@/components/StorageDemo"; 17 | import { EnvDemo } from "@/components/EnvDemo"; 18 | 19 | export const HomeScreen = () => { 20 | const [pokemonName, setPokemonName] = useState("pikachu"); 21 | const [inputValue, setInputValue] = useState(""); 22 | const { data, error, isLoading } = usePokemon(pokemonName); 23 | const colorScheme = useColorScheme(); 24 | const isDark = colorScheme === "dark"; 25 | 26 | // Track when we're intentionally loading a new Pokémon 27 | const [isChangingPokemon, setIsChangingPokemon] = useState(false); 28 | 29 | const handleSearch = () => { 30 | if (inputValue.trim()) { 31 | setIsChangingPokemon(true); 32 | setPokemonName(inputValue.trim().toLowerCase()); 33 | } 34 | }; 35 | 36 | const getRandomPokemon = () => { 37 | setIsChangingPokemon(true); 38 | // Pokémon IDs range from 1 to approximately 1010 in the latest generations 39 | const randomId = Math.floor(Math.random() * 1010) + 1; 40 | setPokemonName(randomId.toString()); 41 | setInputValue(""); // Clear the input field 42 | }; 43 | 44 | // Reset loading state when data changes 45 | useEffect(() => { 46 | if (data && isChangingPokemon) { 47 | setIsChangingPokemon(false); 48 | } 49 | }, [data]); 50 | 51 | // Update shared store when Pokémon changes 52 | useEffect(() => { 53 | if (data?.types?.length) { 54 | pokemonStore.setPokemonType(data.types[0]); 55 | } 56 | }, [data]); 57 | 58 | // Get color based on Pokemon type 59 | const getTypeColor = (type: string) => { 60 | const typeColors: Record = { 61 | normal: "#A8A878", 62 | fire: "#F08030", 63 | water: "#6890F0", 64 | electric: "#F8D030", 65 | grass: "#78C850", 66 | ice: "#98D8D8", 67 | fighting: "#C03028", 68 | poison: "#A040A0", 69 | ground: "#E0C068", 70 | flying: "#A890F0", 71 | psychic: "#F85888", 72 | bug: "#A8B820", 73 | rock: "#B8A038", 74 | ghost: "#705898", 75 | dragon: "#7038F8", 76 | dark: "#705848", 77 | steel: "#B8B8D0", 78 | fairy: "#EE99AC", 79 | }; 80 | return typeColors[type] || "#68A090"; 81 | }; 82 | 83 | // Get header color based on Pokemon types 84 | const getHeaderColor = () => { 85 | if (!data?.types?.length) return { light: "#A1CEDC", dark: "#1D3D47" }; 86 | const mainType = data.types[0]; 87 | const color = getTypeColor(mainType); 88 | return { 89 | light: color, 90 | dark: isDark ? `${color}99` : color, // Add transparency for dark mode 91 | }; 92 | }; 93 | 94 | return ( 95 | 104 | ) : ( 105 | 106 | 110 | 111 | {isChangingPokemon ? "Catching Pokémon..." : "Loading..."} 112 | 113 | 114 | ) 115 | } 116 | > 117 | 118 | {/* Search and Random Buttons */} 119 | 120 | 128 | 136 | 137 | 138 | 139 | 140 | {/* Random Button */} 141 | 148 | 154 | 155 | {isChangingPokemon ? "Searching..." : "Random Pokémon"} 156 | 157 | 158 | 159 | {/* Pokemon Content */} 160 | {isLoading || isChangingPokemon ? ( 161 | 162 | 163 | 164 | {isChangingPokemon ? "Catching Pokémon..." : "Loading..."} 165 | 166 | 167 | ) : error ? ( 168 | 169 | 170 | 171 | Pokémon not found! Try another name. 172 | 173 | 174 | ) : ( 175 | data && ( 176 | 177 | #{data.id} 178 | {data.name} 179 | 180 | {/* Types */} 181 | 182 | {data.types.map((type) => ( 183 | 190 | {type} 191 | 192 | ))} 193 | 194 | 195 | {/* Basic Info */} 196 | 197 | 198 | 199 | {data.height} m 200 | 201 | Height 202 | 203 | 204 | 205 | {data.weight} kg 206 | 207 | Weight 208 | 209 | 210 | 211 | {/* Stats */} 212 | Base Stats 213 | 214 | {data.stats.map((stat) => ( 215 | 216 | 217 | {stat.name.replace("-", " ")} 218 | 219 | 220 | {stat.value} 221 | 222 | 223 | 90 233 | ? "#78C850" 234 | : stat.value > 50 235 | ? "#6890F0" 236 | : "#F08030", 237 | }, 238 | ]} 239 | /> 240 | 241 | 242 | ))} 243 | 244 | 245 | ) 246 | )} 247 | 248 | {/* Storage Demo Section */} 249 | 250 | 251 | {/* Environment Variables Demo Section */} 252 | 253 | 254 | 255 | ); 256 | }; 257 | 258 | const styles = StyleSheet.create({ 259 | reactLogo: { 260 | height: 178, 261 | width: 290, 262 | bottom: 0, 263 | left: 0, 264 | position: "absolute", 265 | }, 266 | pokemonHeaderImage: { 267 | height: 300, 268 | width: 300, 269 | alignSelf: "center", 270 | marginBottom: 20, 271 | }, 272 | container: { 273 | padding: 16, 274 | borderTopLeftRadius: 30, 275 | borderTopRightRadius: 30, 276 | marginTop: -30, 277 | }, 278 | searchContainer: { 279 | flexDirection: "row", 280 | alignItems: "center", 281 | marginBottom: 24, 282 | width: "100%", 283 | }, 284 | input: { 285 | flex: 1, 286 | height: 50, 287 | borderRadius: 25, 288 | paddingHorizontal: 20, 289 | fontSize: 16, 290 | backgroundColor: "#f5f5f5", 291 | shadowColor: "#000", 292 | shadowOffset: { width: 0, height: 2 }, 293 | shadowOpacity: 0.1, 294 | shadowRadius: 4, 295 | elevation: 2, 296 | color: "#333", 297 | }, 298 | searchButton: { 299 | backgroundColor: "#3b82f6", 300 | width: 50, 301 | height: 50, 302 | borderRadius: 25, 303 | justifyContent: "center", 304 | alignItems: "center", 305 | marginLeft: 10, 306 | }, 307 | loadingContainer: { 308 | alignItems: "center", 309 | justifyContent: "center", 310 | padding: 30, 311 | height: 300, 312 | }, 313 | loadingText: { 314 | marginTop: 16, 315 | fontSize: 16, 316 | }, 317 | errorContainer: { 318 | alignItems: "center", 319 | justifyContent: "center", 320 | padding: 30, 321 | height: 300, 322 | }, 323 | errorText: { 324 | marginTop: 16, 325 | fontSize: 16, 326 | textAlign: "center", 327 | }, 328 | pokemonContainer: { 329 | width: "100%", 330 | alignItems: "center", 331 | }, 332 | pokemonId: { 333 | fontSize: 18, 334 | color: "#666", 335 | marginBottom: 4, 336 | }, 337 | pokemonName: { 338 | marginTop: 10, 339 | paddingTop: 10, 340 | fontSize: 32, 341 | fontWeight: "bold", 342 | textTransform: "capitalize", 343 | marginBottom: 12, 344 | }, 345 | typesContainer: { 346 | flexDirection: "row", 347 | gap: 10, 348 | marginBottom: 24, 349 | }, 350 | typeTag: { 351 | paddingHorizontal: 14, 352 | paddingVertical: 6, 353 | borderRadius: 20, 354 | }, 355 | typeText: { 356 | color: "#fff", 357 | fontSize: 14, 358 | fontWeight: "bold", 359 | textTransform: "capitalize", 360 | }, 361 | infoContainer: { 362 | flexDirection: "row", 363 | justifyContent: "space-around", 364 | width: "100%", 365 | marginBottom: 24, 366 | paddingVertical: 16, 367 | borderRadius: 12, 368 | backgroundColor: "rgba(0,0,0,0.03)", 369 | }, 370 | infoItem: { 371 | alignItems: "center", 372 | width: "45%", 373 | }, 374 | infoLabel: { 375 | fontSize: 14, 376 | color: "#666", 377 | marginTop: 4, 378 | }, 379 | infoValue: { 380 | fontSize: 20, 381 | fontWeight: "bold", 382 | }, 383 | sectionTitle: { 384 | fontSize: 22, 385 | fontWeight: "bold", 386 | alignSelf: "flex-start", 387 | marginBottom: 12, 388 | marginTop: 10, 389 | }, 390 | statsContainer: { 391 | width: "100%", 392 | marginBottom: 20, 393 | }, 394 | statRow: { 395 | flexDirection: "row", 396 | alignItems: "center", 397 | marginBottom: 12, 398 | width: "100%", 399 | }, 400 | statName: { 401 | width: 100, 402 | fontSize: 14, 403 | textTransform: "capitalize", 404 | }, 405 | statValue: { 406 | width: 40, 407 | fontSize: 14, 408 | fontWeight: "bold", 409 | textAlign: "right", 410 | marginRight: 10, 411 | }, 412 | statBarContainer: { 413 | flex: 1, 414 | height: 8, 415 | backgroundColor: "rgba(0,0,0,0.1)", 416 | borderRadius: 4, 417 | overflow: "hidden", 418 | }, 419 | statBar: { 420 | height: "100%", 421 | borderRadius: 4, 422 | }, 423 | randomButton: { 424 | backgroundColor: "#22c55e", // Green shade 425 | flexDirection: "row", 426 | alignItems: "center", 427 | justifyContent: "center", 428 | paddingVertical: 12, 429 | paddingHorizontal: 20, 430 | borderRadius: 25, 431 | marginBottom: 24, 432 | width: "100%", 433 | shadowColor: "#000", 434 | shadowOffset: { width: 0, height: 2 }, 435 | shadowOpacity: 0.1, 436 | shadowRadius: 4, 437 | elevation: 2, 438 | }, 439 | buttonText: { 440 | color: "#fff", 441 | fontWeight: "bold", 442 | fontSize: 16, 443 | }, 444 | buttonIcon: { 445 | marginRight: 8, 446 | }, 447 | loaderContainer: { 448 | height: 300, 449 | width: "100%", 450 | justifyContent: "center", 451 | alignItems: "center", 452 | }, 453 | loaderText: { 454 | marginTop: 12, 455 | fontSize: 16, 456 | fontWeight: "bold", 457 | }, 458 | disabledButton: { 459 | opacity: 0.7, 460 | }, 461 | }); 462 | 463 | export default HomeScreen; 464 | -------------------------------------------------------------------------------- /app/+not-found.tsx: -------------------------------------------------------------------------------- 1 | import { Link, Stack } from "expo-router"; 2 | import { StyleSheet } from "react-native"; 3 | 4 | import { ThemedText } from "@/components/ThemedText"; 5 | import { ThemedView } from "@/components/ThemedView"; 6 | 7 | export default function NotFoundScreen() { 8 | return ( 9 | <> 10 | 11 | 12 | This screen doesn't exist. 13 | 14 | Go to home screen! 15 | 16 | 17 | 18 | ); 19 | } 20 | 21 | const styles = StyleSheet.create({ 22 | container: { 23 | flex: 1, 24 | alignItems: "center", 25 | justifyContent: "center", 26 | padding: 20, 27 | }, 28 | link: { 29 | marginTop: 15, 30 | paddingVertical: 15, 31 | }, 32 | }); 33 | -------------------------------------------------------------------------------- /app/_hooks/usePokemon.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from "@tanstack/react-query"; 2 | 3 | // Expanded interface with more useful Pokemon data 4 | interface PokemonData { 5 | id: number; 6 | name: string; 7 | height: number; 8 | weight: number; 9 | image: string | null; // Main image 10 | types: string[]; // Pokemon types (e.g., fire, water) 11 | stats: { 12 | name: string; 13 | value: number; 14 | }[]; 15 | } 16 | 17 | const fetchPokemon = async (pokemonName: string): Promise => { 18 | const response = await fetch( 19 | `https://pokeapi.co/api/v2/pokemon/${pokemonName}` 20 | ); 21 | if (!response.ok) { 22 | throw new Error("Network response was not ok"); 23 | } 24 | const data = await response.json(); 25 | 26 | // Create a more detailed but still clean data object 27 | return { 28 | id: data.id, 29 | name: data.name, 30 | height: data.height / 10, // Convert to meters 31 | weight: data.weight / 10, // Convert to kg 32 | image: 33 | data.sprites.other["official-artwork"].front_default || 34 | data.sprites.front_default, 35 | types: data.types.map((t: any) => t.type.name), 36 | stats: data.stats.map((s: any) => ({ 37 | name: s.stat.name, 38 | value: s.base_stat, 39 | })), 40 | }; 41 | }; 42 | 43 | export const usePokemon = (pokemonName: string) => { 44 | return useQuery({ 45 | queryKey: [`Pokemon-${pokemonName}`], 46 | queryFn: () => fetchPokemon(pokemonName), 47 | enabled: pokemonName.length > 0, 48 | }); 49 | }; 50 | -------------------------------------------------------------------------------- /app/_layout.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | DarkTheme, 3 | DefaultTheme, 4 | ThemeProvider, 5 | } from "@react-navigation/native"; 6 | import { useFonts } from "expo-font"; 7 | import { Stack } from "expo-router"; 8 | import * as SplashScreen from "expo-splash-screen"; 9 | import { StatusBar } from "expo-status-bar"; 10 | import { useEffect } from "react"; 11 | import "react-native-reanimated"; 12 | import { QueryClientProvider, QueryClient } from "@tanstack/react-query"; 13 | import * as Clipboard from "expo-clipboard"; 14 | // import { useSyncQueries } from "tanstack-query-dev-tools-expo-plugin"; 15 | import { useSyncQueriesExternal } from "react-query-external-sync"; 16 | import { useColorScheme } from "@/hooks/useColorScheme"; 17 | import { Platform } from "react-native"; 18 | import AsyncStorage from "@react-native-async-storage/async-storage"; 19 | import * as SecureStore from "expo-secure-store"; 20 | import { storage } from "../storage/mmkv"; 21 | import { DevToolsBubble } from "./dev-tools-bubble"; 22 | 23 | // Prevent the splash screen from auto-hiding before asset loading is complete. 24 | SplashScreen.preventAutoHideAsync(); 25 | 26 | // Create QueryClient as a singleton outside the component 27 | const queryClient = new QueryClient(); 28 | 29 | // Initialize default storage values 30 | const initializeDefaultStorageValues = async () => { 31 | try { 32 | // Set default MMKV value 33 | await storage.setAsync("demo_mmkv_value", "Hello from Mock MMKV!"); 34 | 35 | // Set default AsyncStorage value 36 | await AsyncStorage.setItem("demo_async_value", "Hello from AsyncStorage!"); 37 | 38 | // Set default SecureStore value 39 | await SecureStore.setItemAsync("userToken", "demo-jwt-token-12345"); 40 | 41 | console.log("Default storage values initialized"); 42 | } catch (error) { 43 | console.error("Error initializing default storage values:", error); 44 | } 45 | }; 46 | 47 | export default function RootLayout() { 48 | // Expo dev plugin 49 | // useSyncQueries({ queryClient }); 50 | // New external devtools with storage and environment variable sync 51 | useSyncQueriesExternal({ 52 | queryClient, 53 | socketURL: "http://localhost:42831", // Default port for React Native DevTools 54 | deviceName: Platform?.OS + "pokemon", // Platform detection 55 | platform: Platform?.OS, // Use appropriate platform identifier 56 | deviceId: Platform?.OS + "pokemon", // Use a PERSISTENT identifier (see note below) 57 | extraDeviceInfo: { 58 | // Optional additional info about your device 59 | appVersion: "1.0.0", 60 | // Add any relevant platform info 61 | }, 62 | enableLogs: true, // Enable logs to see storage sync in action 63 | envVariables: { 64 | NODE_ENV: process.env.NODE_ENV || "development", 65 | SECRET_KEY: process.env.SECRET_KEY || "demo-secret-key", 66 | DATABASE_URL: process.env.DATABASE_URL || "sqlite://demo.db", 67 | API_SECRET: process.env.API_SECRET || "demo-api-secret", 68 | // Public environment variables are automatically loaded 69 | }, 70 | // Storage monitoring with CRUD operations 71 | // mmkvStorage: storage, // MMKV storage for ['#storage', 'mmkv', 'key'] queries + monitoring 72 | // asyncStorage: AsyncStorage, // AsyncStorage for ['#storage', 'async', 'key'] queries + monitoring 73 | // secureStorage: SecureStore, // SecureStore for ['#storage', 'secure', 'key'] queries + monitoring 74 | // secureStorageKeys: [ 75 | // "userToken", 76 | // "refreshToken", 77 | // "biometricKey", 78 | // "deviceId", 79 | // "userPreferences", 80 | // "authSecret", 81 | // ], // SecureStore keys to monitor 82 | }); 83 | const colorScheme = useColorScheme(); 84 | const [loaded] = useFonts({ 85 | SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"), 86 | }); 87 | 88 | useEffect(() => { 89 | if (loaded) { 90 | SplashScreen.hideAsync(); 91 | } 92 | }, [loaded]); 93 | 94 | // Initialize default storage values on app load 95 | useEffect(() => { 96 | initializeDefaultStorageValues(); 97 | }, []); 98 | 99 | if (!loaded) { 100 | return null; 101 | } 102 | 103 | return ( 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | { 114 | try { 115 | console.log("Attempting to copy:", text); 116 | await Clipboard.setStringAsync(text); 117 | console.log("Copy successful"); 118 | return true; 119 | } catch (error) { 120 | console.error("Failed to copy to clipboard:", error); 121 | return false; 122 | } 123 | }} 124 | /> 125 | 126 | ); 127 | } 128 | -------------------------------------------------------------------------------- /app/dev-tools-bubble/DevTools.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { 3 | View, 4 | TouchableOpacity, 5 | Text, 6 | StyleSheet, 7 | PanResponderInstance, 8 | } from "react-native"; 9 | import { 10 | Query, 11 | Mutation, 12 | onlineManager, 13 | useQueryClient, 14 | } from "@tanstack/react-query"; 15 | import QueriesList from "./_components/devtools/QueriesList"; 16 | import Svg, { Path } from "react-native-svg"; 17 | import MutationsList from "./_components/devtools/MutationsList"; 18 | import DevToolsHeader from "./_components/devtools/DevToolsHeader"; 19 | 20 | interface Props { 21 | setShowDevTools: React.Dispatch>; 22 | onSelectionChange?: (hasSelection: boolean) => void; 23 | panResponder?: PanResponderInstance; 24 | } 25 | 26 | export default function DevTools({ 27 | setShowDevTools, 28 | onSelectionChange, 29 | panResponder, 30 | }: Props) { 31 | const queryClient = useQueryClient(); 32 | const [showQueries, setShowQueries] = useState(true); 33 | const [selectedQuery, setSelectedQuery] = useState( 34 | undefined 35 | ); 36 | const [selectedMutation, setSelectedMutation] = useState< 37 | Mutation | undefined 38 | >(undefined); 39 | const [isOffline, setIsOffline] = useState(!onlineManager.isOnline()); 40 | 41 | // Clear selections when switching tabs 42 | const handleTabChange = (newShowQueries: boolean) => { 43 | if (newShowQueries !== showQueries) { 44 | setSelectedQuery(undefined); 45 | setSelectedMutation(undefined); 46 | } 47 | setShowQueries(newShowQueries); 48 | }; 49 | 50 | // Handle network toggle 51 | const handleToggleNetwork = () => { 52 | const newOfflineState = !isOffline; 53 | setIsOffline(newOfflineState); 54 | onlineManager.setOnline(!newOfflineState); 55 | }; 56 | 57 | // Handle cache clearing 58 | const handleClearCache = () => { 59 | if (showQueries) { 60 | queryClient.getQueryCache().clear(); 61 | setSelectedQuery(undefined); 62 | } else { 63 | queryClient.getMutationCache().clear(); 64 | setSelectedMutation(undefined); 65 | } 66 | }; 67 | 68 | // Notify parent when selection state changes 69 | React.useEffect(() => { 70 | const hasSelection = 71 | selectedQuery !== undefined || selectedMutation !== undefined; 72 | onSelectionChange?.(hasSelection); 73 | }, [selectedQuery, selectedMutation, onSelectionChange]); 74 | 75 | return ( 76 | 77 | { 79 | setShowDevTools(false); 80 | }} 81 | style={styles.closeButton} 82 | > 83 | 84 | 91 | 92 | 93 | 94 | 104 | {showQueries ? ( 105 | 109 | ) : ( 110 | 114 | )} 115 | 116 | 117 | ); 118 | } 119 | const styles = StyleSheet.create({ 120 | container: { 121 | flex: 1, 122 | flexDirection: "column", 123 | }, 124 | closeButton: { 125 | position: "absolute", 126 | right: -2, 127 | top: -17, 128 | zIndex: 50, 129 | width: 22, 130 | height: 15, 131 | borderTopLeftRadius: 4, 132 | borderTopRightRadius: 4, 133 | backgroundColor: "white", 134 | padding: 3, 135 | margin: 3, 136 | borderColor: "#98a2b3", 137 | borderWidth: 1, 138 | borderBottomWidth: 0, 139 | alignItems: "center", 140 | justifyContent: "center", 141 | }, 142 | devToolsPanel: { 143 | backgroundColor: "white", 144 | minWidth: 300, 145 | flex: 1, 146 | borderTopColor: "#98a2b3", 147 | borderTopWidth: 1, 148 | }, 149 | comingSoonText: { 150 | margin: 3, 151 | }, 152 | }); 153 | -------------------------------------------------------------------------------- /app/dev-tools-bubble/DevToolsBubble.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef } from "react"; 2 | import { 3 | View, 4 | TouchableOpacity, 5 | Platform, 6 | StyleSheet, 7 | ViewStyle, 8 | StyleProp, 9 | Dimensions, 10 | PanResponder, 11 | Animated, 12 | } from "react-native"; 13 | import DevTools from "./DevTools"; 14 | import { TanstackLogo } from "./_components/devtools/svgs"; 15 | import { ClipboardFunction, CopyContext } from "./context/CopyContext"; 16 | 17 | interface DevToolsBubbleProps { 18 | bubbleStyle?: StyleProp; 19 | onCopy?: ClipboardFunction; 20 | } 21 | 22 | export function DevToolsBubble({ bubbleStyle, onCopy }: DevToolsBubbleProps) { 23 | const [showDevTools, setShowDevTools] = useState(false); 24 | const [hasSelection, setHasSelection] = useState(false); 25 | 26 | // Get screen dimensions 27 | const screenHeight = Dimensions.get("window").height; 28 | const expandedHeight = screenHeight * 0.75; 29 | const defaultHeight = 350; 30 | const minHeight = 200; // Minimum height for the panel 31 | const maxHeight = screenHeight * 0.9; // Maximum height (90% of screen) 32 | 33 | // Animated value for height 34 | const heightAnim = useRef( 35 | new Animated.Value(hasSelection ? expandedHeight : defaultHeight) 36 | ).current; 37 | const [currentHeight, setCurrentHeight] = useState( 38 | hasSelection ? expandedHeight : defaultHeight 39 | ); 40 | const currentHeightRef = useRef( 41 | hasSelection ? expandedHeight : defaultHeight 42 | ); 43 | 44 | // Update height when selection changes 45 | React.useEffect(() => { 46 | const targetHeight = hasSelection ? expandedHeight : defaultHeight; 47 | setCurrentHeight(targetHeight); 48 | currentHeightRef.current = targetHeight; 49 | Animated.timing(heightAnim, { 50 | toValue: targetHeight, 51 | duration: 300, 52 | useNativeDriver: false, 53 | }).start(); 54 | }, [hasSelection, expandedHeight, defaultHeight, heightAnim]); 55 | 56 | // Pan responder for dragging 57 | const panResponder = useRef( 58 | PanResponder.create({ 59 | onMoveShouldSetPanResponder: (evt, gestureState) => { 60 | // Only respond to vertical movements 61 | return ( 62 | Math.abs(gestureState.dy) > Math.abs(gestureState.dx) && 63 | Math.abs(gestureState.dy) > 10 64 | ); 65 | }, 66 | onPanResponderGrant: () => { 67 | // Stop any ongoing animations and sync the ref with current animated value 68 | heightAnim.stopAnimation((value) => { 69 | setCurrentHeight(value); 70 | currentHeightRef.current = value; 71 | heightAnim.setValue(value); 72 | }); 73 | }, 74 | onPanResponderMove: (evt, gestureState) => { 75 | // Use the ref value which is always current 76 | const newHeight = currentHeightRef.current - gestureState.dy; 77 | 78 | // Clamp the height between min and max 79 | const clampedHeight = Math.max( 80 | minHeight, 81 | Math.min(maxHeight, newHeight) 82 | ); 83 | heightAnim.setValue(clampedHeight); 84 | }, 85 | onPanResponderRelease: (evt, gestureState) => { 86 | // Calculate the final height using the ref 87 | const finalHeight = Math.max( 88 | minHeight, 89 | Math.min(maxHeight, currentHeightRef.current - gestureState.dy) 90 | ); 91 | 92 | // Update both state and ref immediately 93 | setCurrentHeight(finalHeight); 94 | currentHeightRef.current = finalHeight; 95 | 96 | // Animate to the final height and ensure sync 97 | Animated.timing(heightAnim, { 98 | toValue: finalHeight, 99 | duration: 200, 100 | useNativeDriver: false, 101 | }).start(() => { 102 | // Ensure the animated value and state are perfectly synced after animation 103 | heightAnim.setValue(finalHeight); 104 | setCurrentHeight(finalHeight); 105 | currentHeightRef.current = finalHeight; 106 | }); 107 | }, 108 | }) 109 | ).current; 110 | 111 | return ( 112 | 113 | 114 | {showDevTools ? ( 115 | 116 | 121 | 122 | ) : ( 123 | { 125 | setShowDevTools(true); 126 | }} 127 | style={[ 128 | styles.touchableOpacityBase, 129 | Platform.OS === "ios" 130 | ? styles.touchableOpacityIOS 131 | : styles.touchableOpacityAndroid, 132 | bubbleStyle, 133 | ]} 134 | > 135 | 136 | 137 | )} 138 | 139 | 140 | ); 141 | } 142 | 143 | const styles = StyleSheet.create({ 144 | devTools: { 145 | position: "absolute", 146 | right: 0, 147 | bottom: 0, 148 | zIndex: 50, 149 | width: "100%", 150 | // height is now dynamic, controlled by Animated.Value 151 | }, 152 | touchableOpacityBase: { 153 | position: "absolute", 154 | right: 1, 155 | zIndex: 50, 156 | width: 48, 157 | height: 48, 158 | borderRadius: 24, 159 | borderWidth: 4, 160 | borderColor: "#A4C200", 161 | }, 162 | touchableOpacityIOS: { 163 | bottom: 96, 164 | }, 165 | touchableOpacityAndroid: { 166 | bottom: 64, 167 | }, 168 | text: { 169 | zIndex: 10, 170 | color: "white", 171 | fontSize: 40, 172 | padding: 24, 173 | }, 174 | }); 175 | -------------------------------------------------------------------------------- /app/dev-tools-bubble/_components/_hooks/useAllMutations.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from "react"; 2 | import { Mutation, useQueryClient } from "@tanstack/react-query"; 3 | import isEqual from "fast-deep-equal"; 4 | 5 | function useAllMutations() { 6 | const queryClient = useQueryClient(); 7 | const [mutations, setMutations] = useState([]); 8 | const mutationsRef = useRef([]); 9 | useEffect(() => { 10 | const updateMutations = () => { 11 | // Only update state if the new mutations array is different 12 | setTimeout(() => { 13 | const newMutations = [...queryClient.getMutationCache().getAll()]; 14 | const newStates = newMutations.map((mutation) => mutation.state); 15 | if (!isEqual(mutationsRef.current, newStates)) { 16 | mutationsRef.current = newStates; // Update the ref 17 | setMutations(newMutations); // Update state 18 | } 19 | }, 1); 20 | }; 21 | // Perform an initial update 22 | updateMutations(); 23 | // Subscribe to the query cache to run updates on changes 24 | const unsubscribe = queryClient 25 | .getMutationCache() 26 | .subscribe(updateMutations); 27 | // Cleanup the subscription when the component unmounts 28 | return () => unsubscribe(); 29 | }, [queryClient]); 30 | 31 | return { mutations }; 32 | } 33 | 34 | export default useAllMutations; 35 | -------------------------------------------------------------------------------- /app/dev-tools-bubble/_components/_hooks/useAllQueries.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { Query, useQueryClient } from "@tanstack/react-query"; 3 | function useAllQueries() { 4 | const queryClient = useQueryClient(); 5 | const [queries, setQueries] = useState([]); 6 | useEffect(() => { 7 | const updateQueries = () => { 8 | const allQueries = queryClient.getQueryCache().findAll(); 9 | setTimeout(() => { 10 | setQueries(allQueries); 11 | }, 1); 12 | }; 13 | // Perform an initial update 14 | updateQueries(); 15 | // Subscribe to the query cache to run updates on changes 16 | const unsubscribe = queryClient.getQueryCache().subscribe(updateQueries); 17 | // Cleanup the subscription when the component unmounts 18 | return () => unsubscribe(); 19 | }, [queryClient]); 20 | 21 | return queries; 22 | } 23 | 24 | export default useAllQueries; 25 | -------------------------------------------------------------------------------- /app/dev-tools-bubble/_components/_hooks/useQueryStatusCounts.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { useQueryClient } from "@tanstack/react-query"; 3 | import { getQueryStatusLabel } from "../_util/getQueryStatusLabel"; 4 | 5 | interface QueryStatusCounts { 6 | fresh: number; 7 | stale: number; 8 | fetching: number; 9 | paused: number; 10 | inactive: number; 11 | } 12 | 13 | function useQueryStatusCounts(): QueryStatusCounts { 14 | const queryClient = useQueryClient(); 15 | const [counts, setCounts] = useState({ 16 | fresh: 0, 17 | stale: 0, 18 | fetching: 0, 19 | paused: 0, 20 | inactive: 0, 21 | }); 22 | 23 | useEffect(() => { 24 | const updateCounts = () => { 25 | const allQueries = queryClient.getQueryCache().getAll(); 26 | 27 | const newCounts = allQueries.reduce( 28 | (acc, query) => { 29 | const status = getQueryStatusLabel(query); 30 | acc[status] = (acc[status] || 0) + 1; 31 | return acc; 32 | }, 33 | { fresh: 0, stale: 0, fetching: 0, paused: 0, inactive: 0 } 34 | ); 35 | 36 | setCounts(newCounts); 37 | }; 38 | 39 | // Perform an initial update 40 | updateCounts(); 41 | 42 | // Subscribe to the query cache to run updates on changes 43 | const unsubscribe = queryClient.getQueryCache().subscribe(updateCounts); 44 | 45 | // Cleanup the subscription when the component unmounts 46 | return () => unsubscribe(); 47 | }, [queryClient]); 48 | 49 | return counts; 50 | } 51 | 52 | export default useQueryStatusCounts; 53 | -------------------------------------------------------------------------------- /app/dev-tools-bubble/_components/_util/actions/dataSyncFromServer.ts: -------------------------------------------------------------------------------- 1 | import { Query, useQueryClient } from "@tanstack/react-query"; 2 | 3 | interface Props { 4 | queryClient: ReturnType; 5 | query: Query; 6 | } 7 | export default function dataSyncFromServer({ query, queryClient }: Props) { 8 | queryClient.resetQueries({ 9 | queryKey: query.queryKey, 10 | exact: true, 11 | }); 12 | } 13 | -------------------------------------------------------------------------------- /app/dev-tools-bubble/_components/_util/actions/deleteItem.ts: -------------------------------------------------------------------------------- 1 | import { Query, QueryClient } from "@tanstack/react-query"; 2 | import { deleteNestedDataByPath } from "../deleteNestedDataByPath"; 3 | 4 | interface Props { 5 | queryClient: QueryClient; 6 | activeQuery: Query; 7 | dataPath: Array | undefined; 8 | } 9 | export default function deleteItem({ 10 | activeQuery, 11 | dataPath, 12 | queryClient, 13 | }: Props) { 14 | if (!dataPath) { 15 | console.error("delete item data path is missing!"); 16 | return; 17 | } 18 | const oldData = activeQuery.state.data; 19 | const newData = deleteNestedDataByPath(oldData, dataPath); 20 | queryClient.setQueryData(activeQuery.queryKey, newData); 21 | } 22 | -------------------------------------------------------------------------------- /app/dev-tools-bubble/_components/_util/actions/invalidate.ts: -------------------------------------------------------------------------------- 1 | import { Query, useQueryClient } from "@tanstack/react-query"; 2 | 3 | interface Props { 4 | query: Query; 5 | queryClient: ReturnType; 6 | } 7 | 8 | export default function invalidate({ query, queryClient }: Props) { 9 | // This matches the ACTION-INVALIDATE case from the external sync system 10 | queryClient.invalidateQueries(query); 11 | } 12 | -------------------------------------------------------------------------------- /app/dev-tools-bubble/_components/_util/actions/refetch.ts: -------------------------------------------------------------------------------- 1 | import { Query } from "@tanstack/react-query"; 2 | 3 | interface Props { 4 | query: Query; 5 | } 6 | 7 | export default function refetch({ query }: Props) { 8 | // This matches the ACTION-REFETCH case from the external sync system 9 | const promise = query.fetch(); 10 | promise.catch((error) => { 11 | // Log fetch errors but don't propagate them 12 | console.error(`Refetch error for query:`, error); 13 | }); 14 | } 15 | -------------------------------------------------------------------------------- /app/dev-tools-bubble/_components/_util/actions/remove.ts: -------------------------------------------------------------------------------- 1 | import { Query, useQueryClient } from "@tanstack/react-query"; 2 | 3 | interface Props { 4 | queryClient: ReturnType; 5 | query: Query; 6 | } 7 | export default function remove({ query, queryClient }: Props) { 8 | queryClient.removeQueries(query); 9 | } 10 | -------------------------------------------------------------------------------- /app/dev-tools-bubble/_components/_util/actions/reset.ts: -------------------------------------------------------------------------------- 1 | import { Query, useQueryClient } from "@tanstack/react-query"; 2 | 3 | interface Props { 4 | queryClient: ReturnType; 5 | query: Query; 6 | } 7 | export default function reset({ query, queryClient }: Props) { 8 | queryClient.resetQueries({ 9 | queryKey: query.queryKey, 10 | exact: true, 11 | }); 12 | } 13 | -------------------------------------------------------------------------------- /app/dev-tools-bubble/_components/_util/actions/triggerError.ts: -------------------------------------------------------------------------------- 1 | import { Query, useQueryClient } from "@tanstack/react-query"; 2 | 3 | interface Props { 4 | queryClient: ReturnType; 5 | query: Query; 6 | } 7 | 8 | export default function triggerError({ query, queryClient }: Props) { 9 | if (query.state.status !== "error") { 10 | // --ACTION-TRIGGER-ERROR logic-- 11 | // This matches the ACTION-TRIGGER-ERROR case from the external sync system 12 | const error = new Error("Unknown error from devtools"); 13 | const __previousQueryOptions = query.options; 14 | 15 | query.setState({ 16 | status: "error", 17 | error, 18 | fetchMeta: { 19 | ...query.state.fetchMeta, 20 | // @ts-expect-error This does exist 21 | __previousQueryOptions, 22 | }, 23 | }); 24 | } else { 25 | // --ACTION-RESTORE-ERROR logic-- 26 | // This matches the ACTION-RESTORE-ERROR case from the external sync system 27 | queryClient.resetQueries(query); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /app/dev-tools-bubble/_components/_util/actions/triggerLoading.ts: -------------------------------------------------------------------------------- 1 | import { Query } from "@tanstack/react-query"; 2 | 3 | interface Props { 4 | query: Query; 5 | } 6 | 7 | export default function triggerLoading({ query }: Props) { 8 | if (query.state.data === undefined) { 9 | // --ACTION-RESTORE-LOADING logic-- 10 | // This matches the ACTION-RESTORE-LOADING case from the external sync system 11 | const previousState = query.state; 12 | const previousOptions = query.state.fetchMeta 13 | ? ( 14 | query.state.fetchMeta as unknown as { 15 | __previousQueryOptions: unknown; 16 | } 17 | ).__previousQueryOptions 18 | : null; 19 | 20 | query.cancel({ silent: true }); 21 | query.setState({ 22 | ...previousState, 23 | fetchStatus: "idle", 24 | fetchMeta: null, 25 | }); 26 | 27 | if (previousOptions) { 28 | query.fetch(previousOptions); 29 | } 30 | } else { 31 | // --ACTION-TRIGGER-LOADING logic-- 32 | // This matches the ACTION-TRIGGER-LOADING case from the external sync system 33 | const __previousQueryOptions = query.options; 34 | 35 | // Trigger a fetch in order to trigger suspense as well. 36 | query.fetch({ 37 | ...__previousQueryOptions, 38 | queryFn: () => { 39 | return new Promise(() => { 40 | // Never resolve - simulates perpetual loading 41 | }); 42 | }, 43 | gcTime: -1, 44 | }); 45 | 46 | query.setState({ 47 | data: undefined, 48 | status: "pending", 49 | fetchMeta: { 50 | ...query.state.fetchMeta, 51 | // @ts-expect-error This does exist 52 | __previousQueryOptions, 53 | }, 54 | }); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /app/dev-tools-bubble/_components/_util/deleteNestedDataByPath.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Deletes nested data by path 3 | * 4 | * @param {unknown} oldData Data to be updated 5 | * @param {Array} deletePath Path to the data to be deleted 6 | * @returns newData without the deleted items by path 7 | */ 8 | export const deleteNestedDataByPath = ( 9 | oldData: unknown, 10 | deletePath: Array 11 | ): any => { 12 | if (oldData instanceof Map) { 13 | const newData = new Map(oldData); 14 | 15 | if (deletePath.length === 1) { 16 | newData.delete(deletePath[0]); 17 | return newData; 18 | } 19 | 20 | const [head, ...tail] = deletePath; 21 | newData.set(head, deleteNestedDataByPath(newData.get(head), tail)); 22 | return newData; 23 | } 24 | 25 | if (oldData instanceof Set) { 26 | const setAsArray = deleteNestedDataByPath(Array.from(oldData), deletePath); 27 | return new Set(setAsArray); 28 | } 29 | 30 | if (Array.isArray(oldData)) { 31 | const newData = [...oldData]; 32 | 33 | if (deletePath.length === 1) { 34 | return newData.filter((_, idx) => idx.toString() !== deletePath[0]); 35 | } 36 | 37 | const [head, ...tail] = deletePath; 38 | 39 | // @ts-expect-error NAS 40 | newData[head] = deleteNestedDataByPath(newData[head], tail); 41 | 42 | return newData; 43 | } 44 | 45 | if (oldData instanceof Object) { 46 | const newData = { ...oldData }; 47 | 48 | if (deletePath.length === 1) { 49 | // @ts-expect-error NAS 50 | delete newData[deletePath[0]]; 51 | return newData; 52 | } 53 | 54 | const [head, ...tail] = deletePath; 55 | // @ts-expect-error NAS 56 | newData[head] = deleteNestedDataByPath(newData[head], tail); 57 | 58 | return newData; 59 | } 60 | 61 | return oldData; 62 | }; 63 | -------------------------------------------------------------------------------- /app/dev-tools-bubble/_components/_util/getQueryStatusColor.ts: -------------------------------------------------------------------------------- 1 | import type { Query } from "@tanstack/query-core"; 2 | 3 | export function getQueryStatusColor({ 4 | queryState, 5 | observerCount, 6 | isStale, 7 | }: { 8 | queryState: Query["state"]; 9 | observerCount: number; 10 | isStale: boolean; 11 | }) { 12 | return queryState.fetchStatus === "fetching" 13 | ? "blue" 14 | : !observerCount 15 | ? "gray" 16 | : queryState.fetchStatus === "paused" 17 | ? "purple" 18 | : isStale 19 | ? "yellow" 20 | : "green"; 21 | } 22 | -------------------------------------------------------------------------------- /app/dev-tools-bubble/_components/_util/getQueryStatusLabel.ts: -------------------------------------------------------------------------------- 1 | import { QueryKey, Query } from "@tanstack/react-query"; 2 | type QueryStatus = "fetching" | "inactive" | "paused" | "stale" | "fresh"; 3 | 4 | export function getQueryStatusLabel( 5 | query: Query 6 | ): QueryStatus { 7 | return query.state.fetchStatus === "fetching" 8 | ? "fetching" 9 | : !query.getObserversCount() 10 | ? "inactive" 11 | : query.state.fetchStatus === "paused" 12 | ? "paused" 13 | : query.isStale() 14 | ? "stale" 15 | : "fresh"; 16 | } 17 | -------------------------------------------------------------------------------- /app/dev-tools-bubble/_components/_util/mutationStatusToColorClass.ts: -------------------------------------------------------------------------------- 1 | import { Mutation } from "@tanstack/react-query"; 2 | 3 | const colors = { 4 | purpleMutation: "#D9D6FE", 5 | purpleMutationText: "#5925DC", 6 | redMutation: "#fecaca", 7 | redMutationText: "#b91c1c", 8 | yellowMutation: "#FEDF89", 9 | yellowMutationText: "#B54708", 10 | greenMutation: "#A6F4C5", 11 | greenMutationText: "#027A48", 12 | grayMutation: "#eaecf0", 13 | grayMutationText: "#344054", 14 | }; 15 | 16 | export const getMutationStatusColors = ({ 17 | status, 18 | isPaused, 19 | }: { 20 | status: Mutation["state"]["status"]; 21 | isPaused: boolean; 22 | }) => { 23 | let backgroundColor, textColor; 24 | 25 | if (isPaused) { 26 | backgroundColor = colors.purpleMutation; 27 | textColor = colors.purpleMutationText; 28 | } else if (status === "error") { 29 | backgroundColor = colors.redMutation; 30 | textColor = colors.redMutationText; 31 | } else if (status === "pending") { 32 | backgroundColor = colors.yellowMutation; 33 | textColor = colors.yellowMutationText; 34 | } else if (status === "success") { 35 | backgroundColor = colors.greenMutation; 36 | textColor = colors.greenMutationText; 37 | } else { 38 | backgroundColor = colors.grayMutation; 39 | textColor = colors.grayMutationText; 40 | } 41 | 42 | return { backgroundColor, textColor }; 43 | }; 44 | -------------------------------------------------------------------------------- /app/dev-tools-bubble/_components/_util/statusTobgColorClass.ts: -------------------------------------------------------------------------------- 1 | type StatusColorMap = { 2 | [key: string]: { backgroundColor: string }; 3 | }; 4 | 5 | const statusToBgColorStyle: StatusColorMap = { 6 | fresh: { backgroundColor: "#A6F4C5" }, // Green 7 | stale: { backgroundColor: "#FEDF89" }, // Yellow 8 | fetching: { backgroundColor: "#B2DDFF" }, // Blue 9 | paused: { backgroundColor: "#D9D6FE" }, // Indigo 10 | noObserver: { backgroundColor: "#EAECF0" }, // Grey 11 | inactive: { backgroundColor: "#FEDF89" }, // Yellow 12 | }; 13 | 14 | export function getStatusBgColorStyle(status: string): { 15 | backgroundColor: string; 16 | } { 17 | const defaultStyle = { backgroundColor: "#EAECF0" }; // Default to "noObserver" color 18 | return statusToBgColorStyle[status] || defaultStyle; 19 | } 20 | -------------------------------------------------------------------------------- /app/dev-tools-bubble/_components/_util/updateNestedDataByPath.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * updates nested data by path 3 | * 4 | * @param {unknown} oldData Data to be updated 5 | * @param {Array} updatePath Path to the data to be updated 6 | * @param {unknown} value New value 7 | */ 8 | export const updateNestedDataByPath = ( 9 | oldData: unknown, 10 | updatePath: Array, 11 | value: unknown 12 | ): any => { 13 | if (updatePath.length === 0) { 14 | return value; 15 | } 16 | 17 | if (oldData instanceof Map) { 18 | const newData = new Map(oldData); 19 | 20 | if (updatePath.length === 1) { 21 | newData.set(updatePath[0], value); 22 | return newData; 23 | } 24 | 25 | const [head, ...tail] = updatePath; 26 | newData.set(head, updateNestedDataByPath(newData.get(head), tail, value)); 27 | return newData; 28 | } 29 | 30 | if (oldData instanceof Set) { 31 | const setAsArray = updateNestedDataByPath( 32 | Array.from(oldData), 33 | updatePath, 34 | value 35 | ); 36 | 37 | return new Set(setAsArray); 38 | } 39 | 40 | if (Array.isArray(oldData)) { 41 | const newData = [...oldData]; 42 | 43 | if (updatePath.length === 1) { 44 | // @ts-expect-error NAS 45 | newData[updatePath[0]] = value; 46 | return newData; 47 | } 48 | 49 | const [head, ...tail] = updatePath; 50 | // @ts-expect-error NAS 51 | newData[head] = updateNestedDataByPath(newData[head], tail, value); 52 | 53 | return newData; 54 | } 55 | 56 | if (oldData instanceof Object) { 57 | const newData = { ...oldData }; 58 | 59 | if (updatePath.length === 1) { 60 | // @ts-expect-error NAS 61 | newData[updatePath[0]] = value; 62 | return newData; 63 | } 64 | 65 | const [head, ...tail] = updatePath; 66 | // @ts-expect-error NAS 67 | newData[head] = updateNestedDataByPath(newData[head], tail, value); 68 | 69 | return newData; 70 | } 71 | 72 | return oldData; 73 | }; 74 | -------------------------------------------------------------------------------- /app/dev-tools-bubble/_components/devtools/ActionButton.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { TouchableOpacity, Text, View, StyleSheet } from "react-native"; 3 | 4 | // Define the color mappings 5 | const buttonColors = { 6 | btnRefetch: "#1570EF", 7 | btnInvalidate: "#DC6803", 8 | btnReset: "#475467", 9 | btnRemove: "#db2777", 10 | btnTriggerLoading: "#0891b2", 11 | btnTriggerLoadiError: "#ef4444", 12 | }; 13 | 14 | const textColorMappings = { 15 | btnRefetch: "#1570EF", 16 | btnInvalidate: "#DC6803", 17 | btnReset: "#475467", 18 | btnRemove: "#db2777", 19 | btnTriggerLoading: "#0891b2", 20 | btnTriggerLoadiError: "#ef4444", 21 | }; 22 | 23 | interface Props { 24 | onClick: () => void; 25 | text: string; 26 | bgColorClass: keyof typeof buttonColors; 27 | textColorClass: keyof typeof textColorMappings; 28 | disabled: boolean; 29 | } 30 | 31 | export default function ActionButton({ 32 | onClick, 33 | text, 34 | textColorClass, 35 | bgColorClass, 36 | disabled, 37 | }: Props) { 38 | // Map class names to actual color values 39 | const backgroundColor = buttonColors[bgColorClass]; 40 | const textColor = textColorMappings[textColorClass] || "#FFFFFF"; // Default text color 41 | 42 | return ( 43 | 48 | 49 | 52 | {text} 53 | 54 | 55 | ); 56 | } 57 | 58 | const styles = StyleSheet.create({ 59 | button: { 60 | flexDirection: "row", 61 | alignItems: "center", 62 | justifyContent: "center", 63 | borderRadius: 4, 64 | borderWidth: 1, 65 | borderColor: "#d0d5dd", 66 | backgroundColor: "#f2f4f7", 67 | height: 32, 68 | paddingHorizontal: 10, 69 | paddingVertical: 6, 70 | }, 71 | dot: { 72 | width: 6, 73 | height: 6, 74 | borderRadius: 999, 75 | marginRight: 6, 76 | }, 77 | text: { 78 | fontSize: 12, 79 | fontWeight: "400", 80 | }, 81 | }); 82 | -------------------------------------------------------------------------------- /app/dev-tools-bubble/_components/devtools/ClearCacheButton.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { TouchableOpacity, StyleSheet } from "react-native"; 3 | import { Svg, Path } from "react-native-svg"; 4 | 5 | interface ClearCacheButtonProps { 6 | type: "queries" | "mutations"; 7 | onClear: () => void; 8 | disabled?: boolean; 9 | } 10 | 11 | const ClearCacheButton: React.FC = ({ 12 | type, 13 | onClear, 14 | disabled = false, 15 | }) => { 16 | return ( 17 | 26 | 27 | 28 | ); 29 | }; 30 | 31 | // Trash icon component 32 | const TrashIcon = () => ( 33 | 43 | 47 | 48 | ); 49 | 50 | const styles = StyleSheet.create({ 51 | button: { 52 | width: 24, 53 | height: 24, 54 | borderRadius: 4, 55 | backgroundColor: "#f9fafb", 56 | justifyContent: "center", 57 | alignItems: "center", 58 | borderWidth: 1, 59 | borderColor: "#e5e7eb", 60 | }, 61 | disabledButton: { 62 | opacity: 0.5, 63 | backgroundColor: "#f3f4f6", 64 | }, 65 | }); 66 | 67 | export default ClearCacheButton; 68 | -------------------------------------------------------------------------------- /app/dev-tools-bubble/_components/devtools/DevToolsHeader.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | View, 4 | TouchableOpacity, 5 | Text, 6 | StyleSheet, 7 | PanResponderInstance, 8 | } from "react-native"; 9 | import QueryStatusCount from "./QueryStatusCount"; 10 | import NetworkToggleButton from "./NetworkToggleButton"; 11 | import ClearCacheButton from "./ClearCacheButton"; 12 | 13 | interface Props { 14 | showQueries: boolean; 15 | setShowQueries: React.Dispatch>; 16 | setShowDevTools: React.Dispatch>; 17 | onTabChange?: (showQueries: boolean) => void; 18 | panResponder?: PanResponderInstance; 19 | isOffline: boolean; 20 | onToggleNetwork: () => void; 21 | onClearCache: () => void; 22 | } 23 | 24 | export default function DevToolsHeader({ 25 | showQueries, 26 | setShowQueries, 27 | setShowDevTools, 28 | onTabChange, 29 | panResponder, 30 | isOffline, 31 | onToggleNetwork, 32 | onClearCache, 33 | }: Props) { 34 | const handleTabChange = (newShowQueries: boolean) => { 35 | if (onTabChange) { 36 | onTabChange(newShowQueries); 37 | } else { 38 | setShowQueries(newShowQueries); 39 | } 40 | }; 41 | 42 | return ( 43 | 44 | {/* Drag indicator */} 45 | 46 | 47 | 48 | { 51 | setShowDevTools(false); 52 | }} 53 | accessibilityLabel="Close Tanstack query devtools" 54 | > 55 | TANSTACK 56 | React Native 57 | 58 | 59 | 60 | { 62 | handleTabChange(true); 63 | }} 64 | style={[ 65 | styles.toggleButton, 66 | showQueries === true 67 | ? styles.toggleButtonActive 68 | : styles.toggleButtonInactive, 69 | { 70 | borderTopRightRadius: 0, 71 | borderBottomRightRadius: 0, 72 | }, 73 | ]} 74 | > 75 | 83 | Queries 84 | 85 | 86 | { 88 | handleTabChange(false); 89 | }} 90 | style={[ 91 | styles.toggleButton, 92 | showQueries === false 93 | ? styles.toggleButtonActive 94 | : styles.toggleButtonInactive, 95 | { 96 | borderTopLeftRadius: 0, 97 | borderBottomLeftRadius: 0, 98 | }, 99 | ]} 100 | > 101 | 109 | Mutations 110 | 111 | 112 | 113 | 114 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | ); 125 | } 126 | 127 | const styles = StyleSheet.create({ 128 | devToolsHeader: { 129 | padding: 4, 130 | paddingBottom: 4, 131 | paddingTop: 8, 132 | borderColor: "#d0d5dd", 133 | borderBottomWidth: 2, 134 | flexDirection: "column", 135 | gap: 4, 136 | minHeight: 60, 137 | }, 138 | dragIndicator: { 139 | width: 50, 140 | height: 5, 141 | backgroundColor: "#98a2b3", 142 | borderRadius: 3, 143 | alignSelf: "center", 144 | marginBottom: 6, 145 | opacity: 0.8, 146 | }, 147 | mainRow: { 148 | flexDirection: "row", 149 | flexWrap: "wrap", 150 | alignItems: "center", 151 | justifyContent: "flex-start", 152 | gap: 8, 153 | }, 154 | tanstackHeader: { 155 | flexDirection: "column", 156 | gap: 2, 157 | marginHorizontal: 2, 158 | paddingRight: 8, 159 | backgroundColor: "transparent", 160 | borderWidth: 0, 161 | padding: 0, 162 | }, 163 | tanstackText: { 164 | fontSize: 16, 165 | fontWeight: "bold", 166 | lineHeight: 16, 167 | color: "#475467", 168 | }, 169 | reactNativeText: { 170 | fontSize: 12, 171 | fontWeight: "600", 172 | color: "#ea4037", 173 | marginTop: -4, 174 | }, 175 | toggleButtonsContainer: { 176 | flexDirection: "row", 177 | marginLeft: 1, 178 | alignItems: "center", 179 | }, 180 | toggleButton: { 181 | borderTopLeftRadius: 4, 182 | borderBottomLeftRadius: 4, 183 | padding: 4, 184 | borderWidth: 1, 185 | borderColor: "#d0d5dd", 186 | paddingHorizontal: 2, 187 | maxWidth: 100, 188 | borderRadius: 4, 189 | }, 190 | toggleButtonActive: { 191 | backgroundColor: "#F2F4F7", 192 | }, 193 | toggleButtonInactive: { 194 | backgroundColor: "#EAECF0", 195 | }, 196 | toggleButtonText: { 197 | paddingRight: 4, 198 | paddingLeft: 4, 199 | fontSize: 12, 200 | }, 201 | toggleButtonTextActive: { 202 | color: "#344054", 203 | }, 204 | toggleButtonTextInactive: { 205 | color: "#909193", 206 | }, 207 | }); 208 | -------------------------------------------------------------------------------- /app/dev-tools-bubble/_components/devtools/Explorer.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useMemo } from "react"; 2 | import { Query, QueryKey, useQueryClient } from "@tanstack/react-query"; 3 | import { Check, CopiedCopier, Copier, ErrorCopier, List, Trash } from "./svgs"; 4 | import { updateNestedDataByPath } from "../_util/updateNestedDataByPath"; 5 | import { displayValue } from "./displayValue"; 6 | import deleteItem from "../_util/actions/deleteItem"; 7 | import Svg, { Path } from "react-native-svg"; 8 | import { 9 | Text, 10 | TextInput, 11 | TouchableOpacity, 12 | View, 13 | StyleSheet, 14 | Alert, 15 | } from "react-native"; 16 | import { useCopy } from "../../context/CopyContext"; 17 | 18 | function isIterable(x: any): x is Iterable { 19 | return Symbol.iterator in x; 20 | } 21 | /** 22 | * Chunk elements in the array by size 23 | * 24 | * when the array cannot be chunked evenly by size, the last chunk will be 25 | * filled with the remaining elements 26 | * 27 | * @example 28 | * chunkArray(['a','b', 'c', 'd', 'e'], 2) // returns [['a','b'], ['c', 'd'], ['e']] 29 | */ 30 | function chunkArray( 31 | array: Array, 32 | size: number 33 | ): Array> { 34 | if (size < 1) return []; 35 | let i = 0; 36 | const result: Array> = []; 37 | while (i < array.length) { 38 | result.push(array.slice(i, i + size)); 39 | i = i + size; 40 | } 41 | return result; 42 | } 43 | const Expander = ({ expanded }: { expanded: boolean }) => { 44 | return ( 45 | 46 | 47 | 48 | 49 | 50 | ); 51 | }; 52 | type CopyState = "NoCopy" | "SuccessCopy" | "ErrorCopy"; 53 | const CopyButton = ({ value }: { value: any }) => { 54 | const [copyState, setCopyState] = useState("NoCopy"); 55 | const { onCopy } = useCopy(); 56 | 57 | const handleCopy = async () => { 58 | if (!onCopy) { 59 | Alert.alert( 60 | "Warning", 61 | "Copy functionality is not configured. Please add a copy function to DevToolsBubble. See documentation for setup instructions." 62 | ); 63 | return; 64 | } 65 | 66 | try { 67 | const copied = await onCopy(JSON.stringify(value)); 68 | if (copied) { 69 | setCopyState("SuccessCopy"); 70 | setTimeout(() => setCopyState("NoCopy"), 1500); 71 | } else { 72 | setCopyState("ErrorCopy"); 73 | setTimeout(() => setCopyState("NoCopy"), 1500); 74 | } 75 | } catch (error) { 76 | console.error("Copy failed:", error); 77 | setCopyState("ErrorCopy"); 78 | setTimeout(() => setCopyState("NoCopy"), 1500); 79 | } 80 | }; 81 | return ( 82 | 93 | {copyState === "NoCopy" && } 94 | {copyState === "SuccessCopy" && } 95 | {copyState === "ErrorCopy" && } 96 | 97 | ); 98 | }; 99 | const DeleteItemButton = ({ 100 | dataPath, 101 | activeQuery, 102 | }: { 103 | dataPath: Array; 104 | activeQuery: Query | undefined; 105 | }) => { 106 | const queryClient = useQueryClient(); 107 | if (!activeQuery) return null; 108 | return ( 109 | { 111 | deleteItem({ 112 | queryClient, 113 | activeQuery, 114 | dataPath, 115 | }); 116 | }} 117 | style={styles.buttonStyle1} 118 | accessibilityLabel="Delete item" 119 | > 120 | 121 | 122 | ); 123 | }; 124 | const ClearArrayButton = ({ 125 | dataPath, 126 | activeQuery, 127 | }: { 128 | dataPath: Array; 129 | activeQuery: Query | undefined; 130 | }) => { 131 | const queryClient = useQueryClient(); 132 | if (!activeQuery) return null; 133 | 134 | const handleClear = () => { 135 | const oldData = activeQuery.state.data; 136 | const newData = updateNestedDataByPath(oldData, dataPath, []); 137 | queryClient.setQueryData(activeQuery.queryKey, newData); 138 | }; 139 | 140 | return ( 141 | 146 | 147 | 148 | ); 149 | }; 150 | const ToggleValueButton = ({ 151 | dataPath, 152 | activeQuery, 153 | value, 154 | }: { 155 | dataPath: Array; 156 | activeQuery: Query | undefined; 157 | value: any; 158 | }) => { 159 | const queryClient = useQueryClient(); 160 | if (!activeQuery) return null; 161 | 162 | const handleClick = () => { 163 | const oldData = activeQuery.state.data; 164 | const newData = updateNestedDataByPath(oldData, dataPath, !value); 165 | queryClient.setQueryData(activeQuery.queryKey, newData); 166 | }; 167 | 168 | return ( 169 | 174 | 175 | 176 | ); 177 | }; 178 | type Props = { 179 | editable?: boolean; // true 180 | label: string; //Data 181 | value: any; //unknown; // activeQueryStateData() 182 | defaultExpanded?: Array; // {['Data']} // Label for Data Explorer 183 | activeQuery?: Query | undefined; // activeQuery() 184 | dataPath?: Array; 185 | itemsDeletable?: boolean; 186 | }; 187 | export default function Explorer({ 188 | editable, 189 | label, 190 | value, 191 | defaultExpanded, 192 | activeQuery, 193 | dataPath, 194 | itemsDeletable, 195 | }: Props) { 196 | const queryClient = useQueryClient(); 197 | 198 | // Explorer's section is expanded or collapsed 199 | const [isExpanded, setIsExpanded] = useState( 200 | (defaultExpanded || []).includes(label) 201 | ); 202 | const toggleExpanded = () => setIsExpanded((old) => !old); 203 | const [expandedPages, setExpandedPages] = useState>([]); 204 | 205 | // Flattens data to label and value properties for easy rendering. 206 | const subEntries = useMemo(() => { 207 | if (Array.isArray(value)) { 208 | // Handle if array 209 | return value.map((d, i) => ({ 210 | label: i.toString(), 211 | value: d, 212 | })); 213 | } else if ( 214 | value !== null && 215 | typeof value === "object" && 216 | isIterable(value) 217 | ) { 218 | // Handle if object 219 | if (value instanceof Map) { 220 | return Array.from(value, ([key, val]) => ({ 221 | label: key.toString(), 222 | value: val, 223 | })); 224 | } 225 | return Array.from(value, (val, i) => ({ 226 | label: i.toString(), 227 | value: val, 228 | })); 229 | } else if (typeof value === "object" && value !== null) { 230 | return Object.entries(value).map(([key, val]) => ({ 231 | label: key, 232 | value: val, 233 | })); 234 | } 235 | return []; 236 | }, [value]); 237 | 238 | // Identifies the data type of the value prop (e.g., 'array', 'Iterable', 'object') 239 | const valueType = useMemo(() => { 240 | if (Array.isArray(value)) { 241 | return "array"; 242 | } else if ( 243 | value !== null && 244 | typeof value === "object" && 245 | isIterable(value) && 246 | typeof value[Symbol.iterator] === "function" 247 | ) { 248 | return "Iterable"; 249 | } else if (typeof value === "object" && value !== null) { 250 | return "object"; 251 | } 252 | return typeof value; 253 | }, [value]); 254 | 255 | // Takes a long list of items and divides it into smaller groups or 'chunks'. 256 | const subEntryPages = useMemo(() => { 257 | return chunkArray(subEntries, 100); 258 | }, [subEntries]); 259 | 260 | const currentDataPath = dataPath ?? []; // NOT USED FOR DATA EXPLORER 261 | 262 | const handleChange = (isNumber: boolean, newValue: string) => { 263 | if (!activeQuery) return null; 264 | const oldData = activeQuery.state.data; 265 | // If isNumber and newValue is not a number, return 266 | if (isNumber && isNaN(Number(newValue))) return; 267 | const updatedValue = valueType === "number" ? Number(newValue) : newValue; 268 | const newData = updateNestedDataByPath( 269 | oldData, 270 | currentDataPath, 271 | updatedValue 272 | ); 273 | queryClient.setQueryData(activeQuery.queryKey, newData); 274 | }; 275 | 276 | return ( 277 | 278 | 279 | {subEntryPages.length > 0 && ( 280 | <> 281 | 282 | toggleExpanded()} 285 | > 286 | 287 | {label} 288 | {`${ 289 | String(valueType).toLowerCase() === "iterable" 290 | ? "(Iterable) " 291 | : "" 292 | }${subEntries.length} ${ 293 | subEntries.length > 1 ? `items` : `item` 294 | }`} 295 | 296 | {editable && ( 297 | 298 | 299 | {itemsDeletable && activeQuery !== undefined && ( 300 | 304 | )} 305 | {valueType === "array" && activeQuery !== undefined && ( 306 | 310 | )} 311 | 312 | )} 313 | 314 | {isExpanded && ( 315 | <> 316 | {subEntryPages.length === 1 && ( 317 | 318 | {subEntries.map((entry, index) => ( 319 | 333 | ))} 334 | 335 | )} 336 | {subEntryPages.length > 1 && ( 337 | 338 | {subEntryPages.map((entries, index) => ( 339 | 340 | 341 | 343 | setExpandedPages((old) => 344 | old.includes(index) 345 | ? old.filter((d) => d !== index) 346 | : [...old, index] 347 | ) 348 | } 349 | style={styles.pageExpanderButton} 350 | > 351 | 354 | 355 | [{index * 100}...{index * 100 + 99}] 356 | 357 | 358 | {expandedPages.includes(index) && ( 359 | 360 | {entries.map((entry) => ( 361 | 370 | ))} 371 | 372 | )} 373 | 374 | 375 | ))} 376 | 377 | )} 378 | 379 | )} 380 | 381 | )} 382 | {subEntryPages.length === 0 && ( 383 | 384 | {label}: 385 | {editable && 386 | activeQuery !== undefined && 387 | (valueType === "string" || 388 | valueType === "number" || 389 | valueType === "boolean") ? ( 390 | <> 391 | {editable && 392 | activeQuery && 393 | (valueType === "string" || valueType === "number") && ( 394 | 395 | 407 | handleChange(valueType === "number", newValue) 408 | } 409 | /> 410 | {valueType === "number" && ( 411 | 412 | { 415 | // Increment function 416 | const oldData = activeQuery.state.data; 417 | const newData = updateNestedDataByPath( 418 | oldData, 419 | currentDataPath, 420 | value + 1 421 | ); 422 | queryClient.setQueryData( 423 | activeQuery.queryKey, 424 | newData 425 | ); 426 | }} 427 | > 428 | 433 | 434 | 435 | 436 | { 439 | // Decrement function 440 | const oldData = activeQuery.state.data; 441 | const newData = updateNestedDataByPath( 442 | oldData, 443 | currentDataPath, 444 | value - 1 445 | ); 446 | queryClient.setQueryData( 447 | activeQuery.queryKey, 448 | newData 449 | ); 450 | }} 451 | > 452 | 457 | 462 | 463 | 464 | 465 | )} 466 | 467 | )} 468 | {valueType === "boolean" && ( 469 | 470 | 475 | 476 | {displayValue(value)} 477 | 478 | 479 | )} 480 | 481 | ) : ( 482 | {displayValue(value)} 483 | )} 484 | {editable && itemsDeletable && activeQuery !== undefined && ( 485 | 489 | )} 490 | 491 | )} 492 | 493 | 494 | ); 495 | } 496 | const styles = StyleSheet.create({ 497 | buttonStyle3: { 498 | backgroundColor: "transparent", 499 | flexDirection: "row", 500 | alignItems: "center", 501 | justifyContent: "center", 502 | width: 16, 503 | height: 16, 504 | position: "relative", 505 | zIndex: 10, 506 | }, 507 | buttonStyle2: { 508 | backgroundColor: "transparent", 509 | flexDirection: "row", 510 | padding: 0, 511 | alignItems: "center", 512 | justifyContent: "center", 513 | width: 12, 514 | height: 12, 515 | position: "relative", 516 | zIndex: 10, 517 | }, 518 | buttonStyle1: { 519 | backgroundColor: "transparent", 520 | borderColor: "none", 521 | borderWidth: 0, 522 | padding: 0, 523 | alignItems: "center", 524 | justifyContent: "center", 525 | width: 24, 526 | height: 24, 527 | position: "relative", 528 | }, 529 | buttonStyle: { 530 | backgroundColor: "transparent", 531 | color: "#6B7280", 532 | borderWidth: 0, 533 | flexDirection: "row", 534 | alignItems: "center", 535 | justifyContent: "center", 536 | width: 12, 537 | height: 12, 538 | position: "relative", 539 | }, 540 | expanded: { 541 | transform: [{ rotate: "90deg" }], 542 | }, 543 | collapsed: { 544 | transform: [{ rotate: "0deg" }], 545 | }, 546 | minWidthWrapper: { 547 | minWidth: 200, 548 | fontSize: 12, 549 | flexDirection: "row", 550 | flexWrap: "wrap", 551 | width: "100%", 552 | }, 553 | fullWidthMarginRight: { 554 | position: "relative", 555 | width: "100%", 556 | marginRight: 1, 557 | }, 558 | flexRowItemsCenterGap: { 559 | flexDirection: "row", 560 | alignItems: "center", 561 | justifyContent: "space-between", 562 | padding: 4, 563 | }, 564 | expanderButton: { 565 | flexDirection: "row", 566 | alignItems: "center", 567 | height: 24, 568 | backgroundColor: "transparent", 569 | borderWidth: 0, 570 | padding: 2, 571 | }, 572 | textGray500: { 573 | color: "#6B7280", 574 | fontSize: 12, 575 | marginLeft: 4, 576 | }, 577 | flexRowGapItemsCenter: { 578 | flexDirection: "row", 579 | alignItems: "center", 580 | justifyContent: "space-between", 581 | padding: 4, 582 | }, 583 | singleEntryContainer: { 584 | marginLeft: 12, 585 | paddingLeft: 16, 586 | borderLeftWidth: 2, 587 | borderColor: "#D1D5DB", 588 | }, 589 | multiEntryContainer: { 590 | marginLeft: 12, 591 | paddingLeft: 16, 592 | borderLeftWidth: 2, 593 | borderColor: "#D1D5DB", 594 | }, 595 | relativeOutlineNone: { 596 | position: "relative", 597 | }, 598 | pageExpanderButton: { 599 | flexDirection: "row", 600 | alignItems: "center", 601 | backgroundColor: "transparent", 602 | borderWidth: 0, 603 | padding: 0, 604 | }, 605 | entriesContainer: { 606 | marginLeft: 12, 607 | paddingLeft: 16, 608 | borderLeftWidth: 2, 609 | borderColor: "#D1D5DB", 610 | }, 611 | flexRowGapFullWidth: { 612 | flexDirection: "row", 613 | width: "100%", 614 | alignItems: "center", 615 | marginVertical: 6, 616 | lineHeight: 44, 617 | }, 618 | text344054: { 619 | color: "#344054", 620 | height: "100%", 621 | marginRight: 4, 622 | }, 623 | inputContainer: { 624 | flexDirection: "row", 625 | justifyContent: "space-between", 626 | borderWidth: 0, 627 | height: 28, 628 | margin: 2, 629 | paddingVertical: 4, 630 | paddingLeft: 8, 631 | paddingRight: 6, 632 | borderRadius: 4, 633 | backgroundColor: "#EAECF0", 634 | flex: 1, 635 | }, 636 | textNumber: { 637 | color: "#6938EF", 638 | }, 639 | textInput: { 640 | flex: 1, 641 | marginRight: 8, 642 | paddingBottom: 2, 643 | paddingTop: 2, 644 | }, 645 | textString: {}, 646 | numberInputButtons: { 647 | flexDirection: "row", 648 | }, 649 | touchableButton: { 650 | width: 24, 651 | }, 652 | booleanContainer: { 653 | flexDirection: "row", 654 | alignItems: "center", 655 | padding: 6, 656 | borderRadius: 4, 657 | backgroundColor: "#F3F4F6", 658 | flex: 1, 659 | }, 660 | booleanText: { 661 | marginLeft: 8, 662 | color: "#6938EF", 663 | }, 664 | displayValueText: { 665 | flex: 1, 666 | color: "#6938EF", 667 | height: "100%", 668 | }, 669 | }); 670 | -------------------------------------------------------------------------------- /app/dev-tools-bubble/_components/devtools/MutationButton.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Mutation } from "@tanstack/react-query"; 3 | import { TouchableOpacity, Text, View, StyleSheet } from "react-native"; 4 | import { CheckCircle, LoadingCircle, PauseCircle, XCircle } from "./svgs"; 5 | import { getMutationStatusColors } from "../_util/mutationStatusToColorClass"; 6 | import { displayValue } from "./displayValue"; 7 | interface Props { 8 | mutation: Mutation; 9 | setSelected: React.Dispatch< 10 | React.SetStateAction | undefined> 11 | >; 12 | selected: Mutation | undefined; 13 | } 14 | export default function MutationButton({ 15 | mutation, 16 | setSelected, 17 | selected, 18 | }: Props) { 19 | const mutationKey = mutation.options.mutationKey 20 | ? JSON.stringify(displayValue(mutation.options.mutationKey, false)) + " - " 21 | : ""; 22 | const submittedAt = new Date(mutation.state.submittedAt).toLocaleString(); 23 | const value = `${mutationKey}${submittedAt}`; 24 | 25 | const { backgroundColor, textColor } = getMutationStatusColors({ 26 | isPaused: mutation.state.isPaused, 27 | status: mutation.state.status, 28 | }); 29 | return ( 30 | setSelected(mutation === selected ? undefined : mutation)} 32 | style={[ 33 | styles.button, 34 | selected?.mutationId === mutation.mutationId && styles.selected, 35 | ]} 36 | > 37 | 38 | {mutation.state.isPaused && } 39 | {mutation.state.status === "success" && } 40 | {mutation.state.status === "error" && } 41 | {mutation.state.status === "pending" && } 42 | 43 | {value} 44 | 45 | ); 46 | } 47 | 48 | const styles = StyleSheet.create({ 49 | button: { 50 | flexDirection: "row", 51 | alignItems: "center", 52 | justifyContent: "flex-start", 53 | borderBottomWidth: 1, 54 | borderBottomColor: "#d0d5dd", 55 | backgroundColor: "white", 56 | }, 57 | selected: { 58 | backgroundColor: "#eaecf0", 59 | }, 60 | iconContainer: { 61 | padding: 8, 62 | paddingVertical: 6, 63 | }, 64 | text: { 65 | marginLeft: 8, 66 | fontSize: 12, 67 | minWidth: 18, 68 | }, 69 | }); 70 | -------------------------------------------------------------------------------- /app/dev-tools-bubble/_components/devtools/MutationDetails.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Mutation } from "@tanstack/react-query"; 3 | import { View, Text, ScrollView, StyleSheet } from "react-native"; 4 | import { displayValue } from "./displayValue"; 5 | import MutationDetailsChips from "./MutationDetailsChips"; 6 | 7 | interface Props { 8 | selectedMutation: Mutation | undefined; 9 | } 10 | 11 | export default function MutationDetails({ selectedMutation }: Props) { 12 | if (selectedMutation === undefined) { 13 | return null; 14 | } 15 | 16 | const submittedAt = new Date( 17 | selectedMutation.state.submittedAt 18 | ).toLocaleTimeString(); 19 | 20 | return ( 21 | 22 | 23 | Mutation Details 24 | 25 | 26 | 27 | {`${ 28 | selectedMutation.options.mutationKey 29 | ? displayValue(selectedMutation.options.mutationKey, true) 30 | : "No mutationKey found" 31 | }`} 32 | 33 | 34 | 35 | 36 | Submitted At: 37 | {submittedAt} 38 | 39 | 40 | ); 41 | } 42 | 43 | const styles = StyleSheet.create({ 44 | container: { 45 | minWidth: 200, 46 | fontSize: 12, 47 | backgroundColor: "#FFFFFF", 48 | borderRadius: 4, 49 | }, 50 | mutationDetailsText: { 51 | textAlign: "left", 52 | backgroundColor: "#EAECF0", 53 | padding: 8, 54 | fontWeight: "500", 55 | }, 56 | flexRow: { 57 | flexDirection: "row", 58 | justifyContent: "space-between", 59 | padding: 8, 60 | borderBottomWidth: 1, 61 | borderBottomColor: "#F3F4F6", 62 | }, 63 | justifyBetween: { 64 | justifyContent: "space-between", 65 | }, 66 | p1: { 67 | padding: 8, 68 | }, 69 | flex1: { 70 | flex: 1, 71 | }, 72 | flexWrap: { 73 | flexWrap: "wrap", 74 | alignItems: "center", 75 | marginRight: 8, 76 | }, 77 | bgEAECF0: { 78 | backgroundColor: "#EAECF0", 79 | }, 80 | }); 81 | -------------------------------------------------------------------------------- /app/dev-tools-bubble/_components/devtools/MutationDetailsChips.tsx: -------------------------------------------------------------------------------- 1 | import { Mutation } from "@tanstack/react-query"; 2 | import React from "react"; 3 | import { Text, View, StyleSheet } from "react-native"; 4 | 5 | const backgroundColors = { 6 | fresh: "#D1FADF", // Green 7 | stale: "#FEF0C7", // Yellow 8 | fetching: "#D1E9FF", // Blue 9 | paused: "#EBE9FE", // Indigo 10 | inactive: "#F2F4F7", // Grey 11 | }; 12 | 13 | const borderColors = { 14 | fresh: "#32D583", // Green 15 | stale: "#FDB022", // Yellow 16 | fetching: "#53B1FD", // Blue 17 | paused: "#9B8AFB", // Indigo 18 | inactive: "#344054", // Grey 19 | }; 20 | 21 | const textColors = { 22 | fresh: "#027A48", // Green 23 | stale: "#B54708", // Yellow 24 | fetching: "#175CD3", // Blue 25 | paused: "#5925DC", // Indigo 26 | inactive: "#344054", // Grey 27 | }; 28 | interface Props { 29 | status: Mutation["state"]["status"]; 30 | } 31 | export default function QueryDetailsChip({ status }: Props) { 32 | const statusToColor = 33 | status === "pending" 34 | ? "fetching" 35 | : status === "idle" 36 | ? "inactive" 37 | : status === "error" 38 | ? "stale" 39 | : "fresh"; 40 | const backgroundColor = backgroundColors[statusToColor]; 41 | const borderColor = borderColors[statusToColor]; 42 | const textColor = textColors[statusToColor]; 43 | 44 | return ( 45 | 46 | {status} 47 | 48 | ); 49 | } 50 | const styles = StyleSheet.create({ 51 | container: { 52 | padding: 8, 53 | borderWidth: 1, 54 | borderRadius: 4, 55 | margin: 4, 56 | }, 57 | text: { 58 | fontSize: 12, 59 | }, 60 | }); 61 | -------------------------------------------------------------------------------- /app/dev-tools-bubble/_components/devtools/MutationInformation.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Mutation } from "@tanstack/react-query"; 3 | import DataExplorer from "./Explorer"; 4 | import { ScrollView, Text, View, StyleSheet } from "react-native"; 5 | import MutationDetails from "./MutationDetails"; 6 | 7 | interface Props { 8 | selectedMutation: Mutation | undefined; 9 | } 10 | 11 | export default function MutationInformation({ selectedMutation }: Props) { 12 | return ( 13 | 17 | 18 | 19 | 20 | 21 | Variables Details 22 | 23 | 28 | 29 | 30 | 31 | Context Details 32 | 33 | 38 | 39 | 40 | 41 | Data Explorer 42 | 43 | 48 | 49 | 50 | 51 | Mutations Explorer 52 | 53 | 58 | 59 | 60 | 61 | ); 62 | } 63 | 64 | const styles = StyleSheet.create({ 65 | flex1: { 66 | flex: 1, 67 | }, 68 | scrollContent: { 69 | paddingBottom: 16, 70 | }, 71 | section: { 72 | marginBottom: 12, 73 | }, 74 | textHeader: { 75 | textAlign: "left", 76 | backgroundColor: "#EAECF0", 77 | padding: 8, 78 | width: "100%", 79 | fontSize: 12, 80 | fontWeight: "500", 81 | }, 82 | padding: { 83 | padding: 8, 84 | backgroundColor: "#FAFAFA", 85 | }, 86 | }); 87 | -------------------------------------------------------------------------------- /app/dev-tools-bubble/_components/devtools/MutationsList.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef } from "react"; 2 | import { 3 | ScrollView, 4 | View, 5 | StyleSheet, 6 | PanResponder, 7 | Animated, 8 | Dimensions, 9 | } from "react-native"; 10 | import { Mutation } from "@tanstack/react-query"; 11 | import MutationButton from "./MutationButton"; 12 | import MutationInformation from "./MutationInformation"; 13 | import useAllMutations from "../_hooks/useAllMutations"; 14 | 15 | interface Props { 16 | selectedMutation: Mutation | undefined; 17 | setSelectedMutation: React.Dispatch< 18 | React.SetStateAction | undefined> 19 | >; 20 | } 21 | 22 | export default function MutationsList({ 23 | selectedMutation, 24 | setSelectedMutation, 25 | }: Props) { 26 | const { mutations: allmutations } = useAllMutations(); 27 | 28 | // Height management for resizable mutation information panel 29 | const screenHeight = Dimensions.get("window").height; 30 | const defaultInfoHeight = screenHeight * 0.4; // 40% of screen height 31 | const minInfoHeight = 150; 32 | const maxInfoHeight = screenHeight * 0.7; // 70% of screen height 33 | 34 | const infoHeightAnim = useRef(new Animated.Value(defaultInfoHeight)).current; 35 | const [currentInfoHeight, setCurrentInfoHeight] = useState(defaultInfoHeight); 36 | const currentInfoHeightRef = useRef(defaultInfoHeight); 37 | 38 | // Pan responder for dragging the mutation information panel 39 | const infoPanResponder = useRef( 40 | PanResponder.create({ 41 | onMoveShouldSetPanResponder: (evt, gestureState) => { 42 | return ( 43 | Math.abs(gestureState.dy) > Math.abs(gestureState.dx) && 44 | Math.abs(gestureState.dy) > 10 45 | ); 46 | }, 47 | onPanResponderGrant: () => { 48 | infoHeightAnim.stopAnimation((value) => { 49 | setCurrentInfoHeight(value); 50 | currentInfoHeightRef.current = value; 51 | infoHeightAnim.setValue(value); 52 | }); 53 | }, 54 | onPanResponderMove: (evt, gestureState) => { 55 | // Use the ref value which is always current 56 | const newHeight = currentInfoHeightRef.current - gestureState.dy; 57 | const clampedHeight = Math.max( 58 | minInfoHeight, 59 | Math.min(maxInfoHeight, newHeight) 60 | ); 61 | infoHeightAnim.setValue(clampedHeight); 62 | }, 63 | onPanResponderRelease: (evt, gestureState) => { 64 | const finalHeight = Math.max( 65 | minInfoHeight, 66 | Math.min( 67 | maxInfoHeight, 68 | currentInfoHeightRef.current - gestureState.dy 69 | ) 70 | ); 71 | setCurrentInfoHeight(finalHeight); 72 | currentInfoHeightRef.current = finalHeight; 73 | 74 | Animated.timing(infoHeightAnim, { 75 | toValue: finalHeight, 76 | duration: 200, 77 | useNativeDriver: false, 78 | }).start(() => { 79 | // Ensure the animated value and state are perfectly synced after animation 80 | infoHeightAnim.setValue(finalHeight); 81 | setCurrentInfoHeight(finalHeight); 82 | currentInfoHeightRef.current = finalHeight; 83 | }); 84 | }, 85 | }) 86 | ).current; 87 | 88 | return ( 89 | 90 | 91 | {allmutations.map((mutation, inex) => { 92 | return ( 93 | 99 | ); 100 | })} 101 | 102 | {selectedMutation && ( 103 | 106 | {/* Drag handle for resizing */} 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | )} 115 | 116 | ); 117 | } 118 | 119 | const styles = StyleSheet.create({ 120 | container: { 121 | flex: 1, 122 | flexDirection: "column", 123 | width: "100%", 124 | }, 125 | scrollView: { 126 | flex: 1, 127 | flexDirection: "column", 128 | }, 129 | mutationInfo: { 130 | borderTopWidth: 2, 131 | borderTopColor: "#d0d5dd", 132 | backgroundColor: "#ffffff", 133 | }, 134 | dragHandle: { 135 | height: 20, 136 | justifyContent: "center", 137 | alignItems: "center", 138 | backgroundColor: "#f8f9fa", 139 | borderBottomWidth: 1, 140 | borderBottomColor: "#e5e7eb", 141 | }, 142 | dragIndicator: { 143 | width: 50, 144 | height: 4, 145 | backgroundColor: "#98a2b3", 146 | borderRadius: 2, 147 | opacity: 0.8, 148 | }, 149 | mutationInfoContent: { 150 | flex: 1, 151 | }, 152 | }); 153 | -------------------------------------------------------------------------------- /app/dev-tools-bubble/_components/devtools/NetworkToggleButton.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { TouchableOpacity, StyleSheet, View } from "react-native"; 3 | import { Svg, Path } from "react-native-svg"; 4 | 5 | interface NetworkToggleButtonProps { 6 | isOffline: boolean; 7 | onToggle: () => void; 8 | } 9 | 10 | const NetworkToggleButton: React.FC = ({ 11 | isOffline, 12 | onToggle, 13 | }) => { 14 | return ( 15 | 25 | {isOffline ? : } 26 | 27 | ); 28 | }; 29 | 30 | // Wifi icon component 31 | const WifiIcon = () => ( 32 | 40 | 41 | 45 | 46 | ); 47 | 48 | // Offline icon component 49 | const OfflineIcon = () => ( 50 | 58 | 62 | 63 | ); 64 | 65 | const styles = StyleSheet.create({ 66 | button: { 67 | width: 24, 68 | height: 24, 69 | borderRadius: 4, 70 | backgroundColor: "#f9fafb", 71 | justifyContent: "center", 72 | alignItems: "center", 73 | borderWidth: 1, 74 | borderColor: "#e5e7eb", 75 | }, 76 | offlineButton: { 77 | backgroundColor: "#fee2e2", // Light red background for offline state 78 | borderColor: "#fca5a5", 79 | }, 80 | }); 81 | 82 | export default NetworkToggleButton; 83 | -------------------------------------------------------------------------------- /app/dev-tools-bubble/_components/devtools/QueriesList.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef } from "react"; 2 | import { Query } from "@tanstack/react-query"; 3 | import { 4 | FlatList, 5 | View, 6 | StyleSheet, 7 | SafeAreaView, 8 | Text, 9 | PanResponder, 10 | Animated, 11 | Dimensions, 12 | } from "react-native"; 13 | import QueryRow from "./QueryRow"; 14 | import useAllQueries from "../_hooks/useAllQueries"; 15 | import QueryInformation from "./QueryInformation"; 16 | 17 | interface Props { 18 | selectedQuery: Query | undefined; 19 | setSelectedQuery: React.Dispatch>; 20 | } 21 | 22 | export default function QueriesList({ 23 | selectedQuery, 24 | setSelectedQuery, 25 | }: Props) { 26 | // Holds all queries 27 | const allQueries = useAllQueries(); 28 | 29 | // Height management for resizable query information panel 30 | const screenHeight = Dimensions.get("window").height; 31 | const defaultInfoHeight = screenHeight * 0.4; // 40% of screen height 32 | const minInfoHeight = 150; 33 | const maxInfoHeight = screenHeight * 0.7; // 70% of screen height 34 | 35 | const infoHeightAnim = useRef(new Animated.Value(defaultInfoHeight)).current; 36 | const [currentInfoHeight, setCurrentInfoHeight] = useState(defaultInfoHeight); 37 | const currentInfoHeightRef = useRef(defaultInfoHeight); 38 | 39 | // Pan responder for dragging the query information panel 40 | const infoPanResponder = useRef( 41 | PanResponder.create({ 42 | onMoveShouldSetPanResponder: (evt, gestureState) => { 43 | return ( 44 | Math.abs(gestureState.dy) > Math.abs(gestureState.dx) && 45 | Math.abs(gestureState.dy) > 10 46 | ); 47 | }, 48 | onPanResponderGrant: () => { 49 | infoHeightAnim.stopAnimation((value) => { 50 | setCurrentInfoHeight(value); 51 | currentInfoHeightRef.current = value; 52 | infoHeightAnim.setValue(value); 53 | }); 54 | }, 55 | onPanResponderMove: (evt, gestureState) => { 56 | // Use the ref value which is always current 57 | const newHeight = currentInfoHeightRef.current - gestureState.dy; 58 | const clampedHeight = Math.max( 59 | minInfoHeight, 60 | Math.min(maxInfoHeight, newHeight) 61 | ); 62 | infoHeightAnim.setValue(clampedHeight); 63 | }, 64 | onPanResponderRelease: (evt, gestureState) => { 65 | const finalHeight = Math.max( 66 | minInfoHeight, 67 | Math.min( 68 | maxInfoHeight, 69 | currentInfoHeightRef.current - gestureState.dy 70 | ) 71 | ); 72 | setCurrentInfoHeight(finalHeight); 73 | currentInfoHeightRef.current = finalHeight; 74 | 75 | Animated.timing(infoHeightAnim, { 76 | toValue: finalHeight, 77 | duration: 200, 78 | useNativeDriver: false, 79 | }).start(() => { 80 | // Ensure the animated value and state are perfectly synced after animation 81 | infoHeightAnim.setValue(finalHeight); 82 | setCurrentInfoHeight(finalHeight); 83 | currentInfoHeightRef.current = finalHeight; 84 | }); 85 | }, 86 | }) 87 | ).current; 88 | 89 | // Function to handle query selection 90 | const handleQuerySelect = (query: Query) => { 91 | // If deselecting (i.e., clicking the same query), just update the state 92 | if (query === selectedQuery) { 93 | setSelectedQuery(undefined); 94 | return; 95 | } 96 | setSelectedQuery(query); // Update the selected query 97 | }; 98 | 99 | const renderItem = ({ item }: { item: Query }) => ( 100 | 105 | ); 106 | 107 | return ( 108 | 109 | 110 | {allQueries.length > 0 ? ( 111 | 115 | `${JSON.stringify(item.queryKey)}-${index}` 116 | } 117 | contentContainerStyle={styles.listContent} 118 | /> 119 | ) : ( 120 | 121 | No queries found 122 | 123 | )} 124 | 125 | {selectedQuery && ( 126 | 129 | {/* Drag handle for resizing */} 130 | 131 | 132 | 133 | 134 | 138 | 139 | 140 | )} 141 | 142 | ); 143 | } 144 | 145 | const styles = StyleSheet.create({ 146 | container: { 147 | flex: 1, 148 | width: "100%", 149 | }, 150 | listContainer: { 151 | flex: 1, 152 | width: "100%", 153 | backgroundColor: "#ffffff", 154 | }, 155 | listContent: { 156 | flexGrow: 1, 157 | }, 158 | emptyContainer: { 159 | flex: 1, 160 | justifyContent: "center", 161 | alignItems: "center", 162 | padding: 20, 163 | }, 164 | emptyText: { 165 | color: "#6b7280", 166 | fontSize: 16, 167 | }, 168 | queryInformation: { 169 | borderTopWidth: 2, 170 | borderTopColor: "#d0d5dd", 171 | backgroundColor: "#ffffff", 172 | }, 173 | dragHandle: { 174 | height: 20, 175 | justifyContent: "center", 176 | alignItems: "center", 177 | backgroundColor: "#f8f9fa", 178 | borderBottomWidth: 1, 179 | borderBottomColor: "#e5e7eb", 180 | }, 181 | dragIndicator: { 182 | width: 50, 183 | height: 4, 184 | backgroundColor: "#98a2b3", 185 | borderRadius: 2, 186 | opacity: 0.8, 187 | }, 188 | queryInfoContent: { 189 | flex: 1, 190 | }, 191 | }); 192 | -------------------------------------------------------------------------------- /app/dev-tools-bubble/_components/devtools/QueryActions.tsx: -------------------------------------------------------------------------------- 1 | import { Query, QueryKey, useQueryClient } from "@tanstack/react-query"; 2 | import React from "react"; 3 | import ActionButton from "./ActionButton"; 4 | import { getQueryStatusLabel } from "../_util/getQueryStatusLabel"; 5 | import triggerLoading from "../_util/actions/triggerLoading"; 6 | import refetch from "../_util/actions/refetch"; 7 | import reset from "../_util/actions/reset"; 8 | import remove from "../_util/actions/remove"; 9 | import invalidate from "../_util/actions/invalidate"; 10 | import triggerError from "../_util/actions/triggerError"; 11 | import { View, Text, StyleSheet } from "react-native"; 12 | 13 | interface Props { 14 | setSelectedQuery: React.Dispatch< 15 | React.SetStateAction | undefined> 16 | >; 17 | query: Query | undefined; 18 | } 19 | export default function QueryActions({ query, setSelectedQuery }: Props) { 20 | const queryClient = useQueryClient(); 21 | if (query === undefined) { 22 | return null; 23 | } 24 | const queryStatus = query.state.status; 25 | return ( 26 | 27 | Actions 28 | { 31 | refetch({ 32 | query, 33 | }); 34 | }} 35 | bgColorClass="btnRefetch" 36 | text="Refetch" 37 | textColorClass="btnRefetch" 38 | /> 39 | { 42 | invalidate({ query, queryClient }); 43 | }} 44 | bgColorClass="btnInvalidate" 45 | text="Invalidate" 46 | textColorClass="btnInvalidate" 47 | /> 48 | { 51 | reset({ queryClient, query }); 52 | }} 53 | bgColorClass="btnReset" 54 | text="Reset" 55 | textColorClass="btnReset" 56 | /> 57 | { 60 | remove({ queryClient, query }); 61 | setSelectedQuery(undefined); 62 | }} 63 | bgColorClass="btnRemove" 64 | text="Remove" 65 | textColorClass="btnRemove" 66 | /> 67 | { 70 | triggerLoading({ query }); 71 | }} 72 | bgColorClass="btnTriggerLoading" 73 | text={ 74 | query.state.data === undefined ? "Restore Loading" : "Trigger Loading" 75 | } 76 | textColorClass="btnTriggerLoading" 77 | /> 78 | { 81 | triggerError({ query, queryClient }); 82 | }} 83 | bgColorClass="btnTriggerLoadiError" 84 | text={queryStatus === "error" ? "Restore" : "Trigger Error"} 85 | textColorClass="btnTriggerLoadiError" 86 | /> 87 | 88 | ); 89 | } 90 | const styles = StyleSheet.create({ 91 | container: { 92 | minWidth: 50, 93 | fontSize: 12, 94 | flexDirection: "row", 95 | flexWrap: "wrap", 96 | backgroundColor: "#FFFFFF", 97 | borderRadius: 4, 98 | gap: 8, 99 | padding: 8, 100 | }, 101 | headerText: { 102 | textAlign: "left", 103 | backgroundColor: "#EAECF0", 104 | padding: 8, 105 | width: "100%", 106 | fontWeight: "500", 107 | marginBottom: 8, 108 | }, 109 | }); 110 | -------------------------------------------------------------------------------- /app/dev-tools-bubble/_components/devtools/QueryDetails.tsx: -------------------------------------------------------------------------------- 1 | import { Query, QueryKey } from "@tanstack/react-query"; 2 | import React from "react"; 3 | import QueryDetailsChip from "./QueryDetailsChip"; 4 | import { View, Text, ScrollView, StyleSheet } from "react-native"; 5 | import { displayValue } from "./displayValue"; 6 | 7 | interface Props { 8 | query: Query | undefined; 9 | } 10 | export default function QueryDetails({ query }: Props) { 11 | if (query === undefined) { 12 | return null; 13 | } 14 | // Convert the timestamp to a Date object and format it 15 | const lastUpdated = new Date(query.state.dataUpdatedAt).toLocaleTimeString(); 16 | 17 | return ( 18 | 19 | Query Details 20 | 21 | 22 | 23 | {displayValue(query.queryKey, true)} 24 | 25 | 26 | 27 | 28 | 29 | Observers: 30 | {`${query.getObserversCount()}`} 31 | 32 | 33 | Last Updated: 34 | {`${lastUpdated}`} 35 | 36 | 37 | ); 38 | } 39 | const styles = StyleSheet.create({ 40 | minWidth: { 41 | minWidth: 200, 42 | fontSize: 12, 43 | backgroundColor: "#FFFFFF", 44 | borderRadius: 4, 45 | }, 46 | headerText: { 47 | textAlign: "left", 48 | backgroundColor: "#EAECF0", 49 | padding: 8, 50 | fontWeight: "500", 51 | }, 52 | row: { 53 | flexDirection: "row", 54 | justifyContent: "space-between", 55 | padding: 8, 56 | borderBottomWidth: 1, 57 | borderBottomColor: "#F3F4F6", 58 | }, 59 | flexOne: { 60 | flex: 1, 61 | }, 62 | queryKeyText: { 63 | flexWrap: "wrap", 64 | alignItems: "center", 65 | marginRight: 8, 66 | }, 67 | }); 68 | -------------------------------------------------------------------------------- /app/dev-tools-bubble/_components/devtools/QueryDetailsChip.tsx: -------------------------------------------------------------------------------- 1 | import { Query } from "@tanstack/react-query"; 2 | import React from "react"; 3 | import { getQueryStatusLabel } from "../_util/getQueryStatusLabel"; 4 | import { Text, View, StyleSheet } from "react-native"; 5 | interface Props { 6 | query: Query; 7 | } 8 | const backgroundColors = { 9 | fresh: "#D1FADF", // Green 10 | stale: "#FEF0C7", // Yellow 11 | fetching: "#D1E9FF", // Blue 12 | paused: "#EBE9FE", // Indigo 13 | noObserver: "#F2F4F7", // Grey 14 | }; 15 | 16 | const borderColors = { 17 | fresh: "#32D583", // Green 18 | stale: "#FDB022", // Yellow 19 | fetching: "#53B1FD", // Blue 20 | paused: "#9B8AFB", // Indigo 21 | noObserver: "#344054", // Grey 22 | }; 23 | 24 | const textColors = { 25 | fresh: "#027A48", // Green 26 | stale: "#B54708", // Yellow 27 | fetching: "#175CD3", // Blue 28 | paused: "#5925DC", // Indigo 29 | noObserver: "#344054", // Grey 30 | }; 31 | type QueryStatus = "fresh" | "stale" | "fetching" | "paused" | "noObserver"; 32 | 33 | export default function QueryDetailsChip({ query }: Props) { 34 | const status = getQueryStatusLabel(query) as QueryStatus; 35 | const backgroundColor = backgroundColors[status]; 36 | const borderColor = borderColors[status]; 37 | const textColor = textColors[status]; 38 | 39 | return ( 40 | 41 | {status} 42 | 43 | ); 44 | } 45 | const styles = StyleSheet.create({ 46 | container: { 47 | padding: 8, 48 | borderWidth: 1, 49 | borderRadius: 4, 50 | margin: 4, 51 | }, 52 | text: { 53 | fontSize: 12, 54 | }, 55 | }); 56 | -------------------------------------------------------------------------------- /app/dev-tools-bubble/_components/devtools/QueryInformation.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Query } from "@tanstack/react-query"; 3 | import QueryDetails from "./QueryDetails"; 4 | import QueryActions from "./QueryActions"; 5 | import DataExplorer from "./Explorer"; 6 | import { View, Text, ScrollView, StyleSheet } from "react-native"; 7 | 8 | interface Props { 9 | setSelectedQuery: React.Dispatch>; 10 | selectedQuery: Query | undefined; 11 | } 12 | export default function QueryInformation({ 13 | selectedQuery, 14 | setSelectedQuery, 15 | }: Props) { 16 | return ( 17 | 21 | 22 | 23 | 24 | 25 | 29 | 30 | 31 | Data Explorer 32 | 33 | 40 | 41 | 42 | 43 | Query Explorer 44 | 45 | 51 | 52 | 53 | 54 | ); 55 | } 56 | 57 | const styles = StyleSheet.create({ 58 | flexOne: { 59 | flex: 1, 60 | }, 61 | scrollContent: { 62 | paddingBottom: 16, 63 | }, 64 | section: { 65 | marginBottom: 12, 66 | }, 67 | headerText: { 68 | textAlign: "left", 69 | backgroundColor: "#EAECF0", 70 | padding: 8, 71 | width: "100%", 72 | fontSize: 12, 73 | fontWeight: "500", 74 | }, 75 | contentView: { 76 | padding: 8, 77 | backgroundColor: "#FAFAFA", 78 | }, 79 | }); 80 | -------------------------------------------------------------------------------- /app/dev-tools-bubble/_components/devtools/QueryRow.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { View, Text, TouchableOpacity, StyleSheet } from "react-native"; 3 | import { Query } from "@tanstack/react-query"; 4 | import { getQueryStatusLabel } from "../_util/getQueryStatusLabel"; 5 | import { displayValue } from "./displayValue"; 6 | 7 | interface QueryRowProps { 8 | query: Query; 9 | isSelected: boolean; 10 | onSelect: (query: Query) => void; 11 | } 12 | 13 | const QueryRow: React.FC = ({ query, isSelected, onSelect }) => { 14 | // Map status to color names 15 | const getStatusColor = ( 16 | status: string 17 | ): "green" | "yellow" | "gray" | "blue" | "purple" | "red" => { 18 | switch (status) { 19 | case "fresh": 20 | return "green"; 21 | case "stale": 22 | case "inactive": 23 | return "yellow"; 24 | case "fetching": 25 | return "blue"; 26 | case "paused": 27 | return "purple"; 28 | default: 29 | return "gray"; 30 | } 31 | }; 32 | 33 | // Map color names to actual color values 34 | const getColorValue = ( 35 | colorName: "green" | "yellow" | "gray" | "blue" | "purple" | "red", 36 | shade: "200" | "300" | "700" | "800" | "900" 37 | ): string => { 38 | const colors: Record< 39 | "green" | "yellow" | "gray" | "blue" | "purple" | "red", 40 | Record<"200" | "300" | "700" | "800" | "900", string> 41 | > = { 42 | green: { 43 | "200": "#bbf7d0", 44 | "300": "#86efac", 45 | "700": "#15803d", 46 | "800": "#166534", 47 | "900": "#14532d", 48 | }, 49 | yellow: { 50 | "200": "#fef08a", 51 | "300": "#fde047", 52 | "700": "#a16207", 53 | "800": "#854d0e", 54 | "900": "#713f12", 55 | }, 56 | gray: { 57 | "200": "#e5e7eb", 58 | "300": "#d1d5db", 59 | "700": "#374151", 60 | "800": "#1f2937", 61 | "900": "#111827", 62 | }, 63 | blue: { 64 | "200": "#bfdbfe", 65 | "300": "#93c5fd", 66 | "700": "#1d4ed8", 67 | "800": "#1e40af", 68 | "900": "#1e3a8a", 69 | }, 70 | purple: { 71 | "200": "#e9d5ff", 72 | "300": "#d8b4fe", 73 | "700": "#7e22ce", 74 | "800": "#6b21a8", 75 | "900": "#581c87", 76 | }, 77 | red: { 78 | "200": "#fecaca", 79 | "300": "#fca5a5", 80 | "700": "#b91c1c", 81 | "800": "#991b1b", 82 | "900": "#7f1d1d", 83 | }, 84 | }; 85 | 86 | return colors[colorName][shade]; 87 | }; 88 | 89 | const status = getQueryStatusLabel(query); 90 | const statusColor = getStatusColor(status); 91 | const observerCount = query.getObserversCount(); 92 | const isDisabled = query.isDisabled(); 93 | const queryHash = displayValue(query.queryKey, false); 94 | 95 | // Get background and text colors for observer count based on status 96 | const getObserverCountStyles = () => { 97 | if (statusColor === "gray") { 98 | return { 99 | backgroundColor: getColorValue(statusColor, "200"), 100 | color: getColorValue(statusColor, "700"), 101 | }; 102 | } 103 | 104 | return { 105 | backgroundColor: getColorValue(statusColor, "200"), 106 | color: getColorValue(statusColor, "800"), 107 | }; 108 | }; 109 | 110 | return ( 111 | onSelect(query)} 114 | activeOpacity={0.7} 115 | accessibilityLabel={`Query key ${queryHash}`} 116 | > 117 | {/* Observer count badge */} 118 | 119 | 125 | {observerCount} 126 | 127 | 128 | 129 | {/* Query hash/key */} 130 | 131 | {queryHash} 132 | 133 | 134 | {/* Disabled indicator */} 135 | {isDisabled && ( 136 | 137 | disabled 138 | 139 | )} 140 | 141 | ); 142 | }; 143 | 144 | const styles = StyleSheet.create({ 145 | queryRow: { 146 | flexDirection: "row", 147 | alignItems: "stretch", 148 | borderBottomWidth: 1, 149 | borderBottomColor: "#e5e7eb", 150 | backgroundColor: "#ffffff", 151 | }, 152 | selectedQueryRow: { 153 | backgroundColor: "#f3f4f6", 154 | }, 155 | observerCount: { 156 | width: 32, 157 | justifyContent: "center", 158 | alignItems: "center", 159 | marginRight: 0, 160 | }, 161 | observerCountText: { 162 | fontSize: 12, 163 | fontWeight: "600", 164 | fontVariant: ["tabular-nums"], 165 | }, 166 | queryHash: { 167 | flex: 1, 168 | fontFamily: "monospace", 169 | fontSize: 14, 170 | color: "#1f2937", 171 | paddingVertical: 8, 172 | paddingHorizontal: 12, 173 | textAlignVertical: "center", 174 | }, 175 | disabledIndicator: { 176 | backgroundColor: "#f3f4f6", 177 | borderRadius: 4, 178 | paddingHorizontal: 6, 179 | paddingVertical: 2, 180 | marginLeft: 8, 181 | alignSelf: "center", 182 | }, 183 | disabledText: { 184 | fontSize: 12, 185 | color: "#6b7280", 186 | }, 187 | }); 188 | 189 | export default QueryRow; 190 | -------------------------------------------------------------------------------- /app/dev-tools-bubble/_components/devtools/QueryStatus.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { View, Text, TouchableOpacity, StyleSheet } from "react-native"; 3 | 4 | interface QueryStatusProps { 5 | label: string; 6 | color: "green" | "yellow" | "gray" | "blue" | "purple" | "red"; 7 | count: number; 8 | showLabel?: boolean; 9 | } 10 | 11 | type ColorName = "green" | "yellow" | "gray" | "blue" | "purple" | "red"; 12 | type ColorShade = "100" | "200" | "300" | "400" | "500" | "700" | "900"; 13 | 14 | const QueryStatus: React.FC = ({ 15 | label, 16 | color, 17 | count, 18 | showLabel = true, 19 | }) => { 20 | const [isHovered, setIsHovered] = useState(false); 21 | 22 | // Map color names to actual color values 23 | const getColorValue = (colorName: ColorName, shade: ColorShade): string => { 24 | const colors: Record> = { 25 | green: { 26 | "100": "#dcfce7", 27 | "200": "#bbf7d0", 28 | "300": "#86efac", 29 | "400": "#4ade80", 30 | "500": "#22c55e", 31 | "700": "#15803d", 32 | "900": "#14532d", 33 | }, 34 | yellow: { 35 | "100": "#fef9c3", 36 | "200": "#fef08a", 37 | "300": "#fde047", 38 | "400": "#facc15", 39 | "500": "#eab308", 40 | "700": "#a16207", 41 | "900": "#713f12", 42 | }, 43 | gray: { 44 | "100": "#f3f4f6", 45 | "200": "#e5e7eb", 46 | "300": "#d1d5db", 47 | "400": "#9ca3af", 48 | "500": "#6b7280", 49 | "700": "#374151", 50 | "900": "#111827", 51 | }, 52 | blue: { 53 | "100": "#dbeafe", 54 | "200": "#bfdbfe", 55 | "300": "#93c5fd", 56 | "400": "#60a5fa", 57 | "500": "#3b82f6", 58 | "700": "#1d4ed8", 59 | "900": "#1e3a8a", 60 | }, 61 | purple: { 62 | "100": "#f3e8ff", 63 | "200": "#e9d5ff", 64 | "300": "#d8b4fe", 65 | "400": "#c084fc", 66 | "500": "#a855f7", 67 | "700": "#7e22ce", 68 | "900": "#581c87", 69 | }, 70 | red: { 71 | "100": "#fee2e2", 72 | "200": "#fecaca", 73 | "300": "#fca5a5", 74 | "400": "#f87171", 75 | "500": "#ef4444", 76 | "700": "#b91c1c", 77 | "900": "#7f1d1d", 78 | }, 79 | }; 80 | 81 | return colors[colorName]?.[shade] || "#000000"; 82 | }; 83 | 84 | return ( 85 | setIsHovered(true)} 89 | onPressOut={() => setIsHovered(false)} 90 | activeOpacity={0.7} 91 | > 92 | {!showLabel && isHovered && ( 93 | 94 | {label} 95 | 96 | )} 97 | 98 | 101 | 102 | {showLabel && {label}} 103 | 104 | 0 && 108 | color !== "gray" && { 109 | backgroundColor: getColorValue(color, "100"), 110 | }, 111 | ]} 112 | > 113 | 0 && 117 | color !== "gray" && { 118 | color: getColorValue(color, "700"), 119 | }, 120 | ]} 121 | > 122 | {count} 123 | 124 | 125 | 126 | ); 127 | }; 128 | 129 | const styles = StyleSheet.create({ 130 | queryStatusTag: { 131 | flexDirection: "row", 132 | gap: 6, 133 | height: 26, 134 | backgroundColor: "#f9fafb", 135 | borderRadius: 4, 136 | padding: 4, 137 | paddingLeft: 6, 138 | alignItems: "center", 139 | fontWeight: "500", 140 | borderWidth: 1, 141 | borderColor: "#e5e7eb", 142 | position: "relative", 143 | }, 144 | clickable: { 145 | // cursor: 'pointer', // This doesn't exist in React Native 146 | }, 147 | dot: { 148 | width: 6, 149 | height: 6, 150 | borderRadius: 3, 151 | }, 152 | label: { 153 | fontSize: 12, 154 | }, 155 | countContainer: { 156 | fontSize: 12, 157 | paddingHorizontal: 5, 158 | alignItems: "center", 159 | justifyContent: "center", 160 | backgroundColor: "#e5e7eb", 161 | borderRadius: 2, 162 | height: 18, 163 | }, 164 | count: { 165 | fontSize: 12, 166 | color: "#6b7280", 167 | fontVariant: ["tabular-nums"], 168 | }, 169 | tooltip: { 170 | position: "absolute", 171 | zIndex: 1, 172 | backgroundColor: "#f9fafb", 173 | top: "100%", 174 | left: "50%", 175 | transform: [{ translateX: -50 }, { translateY: 8 }], 176 | padding: 2, 177 | paddingHorizontal: 8, 178 | borderRadius: 4, 179 | borderWidth: 1, 180 | borderColor: "#9ca3af", 181 | }, 182 | tooltipText: { 183 | fontSize: 12, 184 | }, 185 | }); 186 | 187 | export default QueryStatus; 188 | -------------------------------------------------------------------------------- /app/dev-tools-bubble/_components/devtools/QueryStatusCount.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { View, StyleSheet } from "react-native"; 3 | import QueryStatus from "./QueryStatus"; 4 | import useQueryStatusCounts from "../_hooks/useQueryStatusCounts"; 5 | 6 | const QueryStatusCount: React.FC = () => { 7 | const { fresh, stale, fetching, paused, inactive } = useQueryStatusCounts(); 8 | 9 | return ( 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | }; 19 | 20 | const styles = StyleSheet.create({ 21 | queryStatusContainer: { 22 | flexDirection: "row", 23 | flexWrap: "wrap", 24 | gap: 4, 25 | alignItems: "center", 26 | justifyContent: "center", 27 | paddingVertical: 2, 28 | paddingHorizontal: 4, 29 | }, 30 | }); 31 | 32 | export default QueryStatusCount; 33 | -------------------------------------------------------------------------------- /app/dev-tools-bubble/_components/devtools/displayValue.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Displays a string regardless the type of the data 3 | * @param {unknown} value Value to be stringified 4 | * @param {boolean} beautify Formats json to multiline 5 | */ 6 | export const displayValue = (value: unknown, beautify: boolean = false) => { 7 | const getCircularReplacer = () => { 8 | const seen = new WeakSet(); 9 | return (key: string, value: any) => { 10 | if (typeof value === "object" && value !== null) { 11 | if (seen.has(value)) { 12 | return "[Circular]"; 13 | } 14 | seen.add(value); 15 | } 16 | return value; 17 | }; 18 | }; 19 | 20 | return JSON.stringify(value, getCircularReplacer(), beautify ? 2 : undefined); 21 | }; 22 | -------------------------------------------------------------------------------- /app/dev-tools-bubble/context/CopyContext.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from "react"; 2 | 3 | export type ClipboardFunction = (text: string) => Promise; 4 | 5 | interface CopyContextType { 6 | onCopy?: ClipboardFunction; 7 | } 8 | 9 | export const CopyContext = createContext({}); 10 | 11 | export const useCopy = () => useContext(CopyContext); 12 | -------------------------------------------------------------------------------- /app/dev-tools-bubble/index.ts: -------------------------------------------------------------------------------- 1 | export { DevToolsBubble } from "./DevToolsBubble"; 2 | -------------------------------------------------------------------------------- /assets/fonts/SpaceMono-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LovesWorking/RN-Dev-Tools-Example/d5cba14f5fd0a92f856ab72e3d3d8c5bb9c1b59f/assets/fonts/SpaceMono-Regular.ttf -------------------------------------------------------------------------------- /assets/images/adaptive-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LovesWorking/RN-Dev-Tools-Example/d5cba14f5fd0a92f856ab72e3d3d8c5bb9c1b59f/assets/images/adaptive-icon.png -------------------------------------------------------------------------------- /assets/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LovesWorking/RN-Dev-Tools-Example/d5cba14f5fd0a92f856ab72e3d3d8c5bb9c1b59f/assets/images/favicon.png -------------------------------------------------------------------------------- /assets/images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LovesWorking/RN-Dev-Tools-Example/d5cba14f5fd0a92f856ab72e3d3d8c5bb9c1b59f/assets/images/icon.png -------------------------------------------------------------------------------- /assets/images/partial-react-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LovesWorking/RN-Dev-Tools-Example/d5cba14f5fd0a92f856ab72e3d3d8c5bb9c1b59f/assets/images/partial-react-logo.png -------------------------------------------------------------------------------- /assets/images/react-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LovesWorking/RN-Dev-Tools-Example/d5cba14f5fd0a92f856ab72e3d3d8c5bb9c1b59f/assets/images/react-logo.png -------------------------------------------------------------------------------- /assets/images/react-logo@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LovesWorking/RN-Dev-Tools-Example/d5cba14f5fd0a92f856ab72e3d3d8c5bb9c1b59f/assets/images/react-logo@2x.png -------------------------------------------------------------------------------- /assets/images/react-logo@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LovesWorking/RN-Dev-Tools-Example/d5cba14f5fd0a92f856ab72e3d3d8c5bb9c1b59f/assets/images/react-logo@3x.png -------------------------------------------------------------------------------- /assets/images/splash-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LovesWorking/RN-Dev-Tools-Example/d5cba14f5fd0a92f856ab72e3d3d8c5bb9c1b59f/assets/images/splash-icon.png -------------------------------------------------------------------------------- /components/Collapsible.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren, useState } from 'react'; 2 | import { StyleSheet, TouchableOpacity } from 'react-native'; 3 | 4 | import { ThemedText } from '@/components/ThemedText'; 5 | import { ThemedView } from '@/components/ThemedView'; 6 | import { IconSymbol } from '@/components/ui/IconSymbol'; 7 | import { Colors } from '@/constants/Colors'; 8 | import { useColorScheme } from '@/hooks/useColorScheme'; 9 | 10 | export function Collapsible({ children, title }: PropsWithChildren & { title: string }) { 11 | const [isOpen, setIsOpen] = useState(false); 12 | const theme = useColorScheme() ?? 'light'; 13 | 14 | return ( 15 | 16 | setIsOpen((value) => !value)} 19 | activeOpacity={0.8}> 20 | 27 | 28 | {title} 29 | 30 | {isOpen && {children}} 31 | 32 | ); 33 | } 34 | 35 | const styles = StyleSheet.create({ 36 | heading: { 37 | flexDirection: 'row', 38 | alignItems: 'center', 39 | gap: 6, 40 | }, 41 | content: { 42 | marginTop: 6, 43 | marginLeft: 24, 44 | }, 45 | }); 46 | -------------------------------------------------------------------------------- /components/EnvDemo.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { StyleSheet, ScrollView } from "react-native"; 3 | import { ThemedText } from "@/components/ThemedText"; 4 | import { ThemedView } from "@/components/ThemedView"; 5 | import { Ionicons } from "@expo/vector-icons"; 6 | 7 | interface EnvItemProps { 8 | name: string; 9 | value: string | undefined; 10 | isPublic: boolean; 11 | } 12 | 13 | const EnvItem: React.FC = ({ name, value, isPublic }) => { 14 | return ( 15 | 21 | 22 | 27 | {name} 28 | 34 | 35 | {isPublic ? "PUBLIC" : "PRIVATE"} 36 | 37 | 38 | 39 | {value || "Not set"} 40 | 41 | ); 42 | }; 43 | 44 | export const EnvDemo: React.FC = () => { 45 | // Get all environment variables that start with EXPO_PUBLIC_ 46 | const publicEnvVars = Object.entries(process.env) 47 | .filter(([key]) => key.startsWith("EXPO_PUBLIC_")) 48 | .sort(([a], [b]) => a.localeCompare(b)); 49 | 50 | // Some private environment variables for demo 51 | const privateEnvVars: [string, string | undefined][] = [ 52 | ["NODE_ENV", process.env.NODE_ENV], 53 | ["SECRET_KEY", process.env.SECRET_KEY], 54 | ["DATABASE_URL", process.env.DATABASE_URL], 55 | ["API_SECRET", process.env.API_SECRET], 56 | ]; 57 | 58 | return ( 59 | 60 | Environment Variables 61 | 62 | Environment variables are automatically synced with DevTools. Public 63 | variables (EXPO_PUBLIC_*) are visible to the client, while private 64 | variables are only available server-side. 65 | 66 | 67 | 71 | {/* Public Environment Variables */} 72 | 73 | 74 | Public Variables ({publicEnvVars.length}) 75 | 76 | {publicEnvVars.length > 0 ? ( 77 | publicEnvVars.map(([key, value]) => ( 78 | 79 | )) 80 | ) : ( 81 | 82 | 83 | 84 | No public environment variables found. Add variables starting 85 | with EXPO_PUBLIC_ to see them here. 86 | 87 | 88 | )} 89 | 90 | 91 | {/* Private Environment Variables */} 92 | 93 | 94 | Private Variables (Demo) 95 | 96 | {privateEnvVars.map(([key, value]) => ( 97 | 98 | ))} 99 | 100 | 101 | 102 | ); 103 | }; 104 | 105 | const styles = StyleSheet.create({ 106 | container: { 107 | marginTop: 32, 108 | marginBottom: 20, 109 | maxHeight: 400, // Limit height to prevent taking too much space 110 | }, 111 | sectionTitle: { 112 | fontSize: 22, 113 | fontWeight: "bold", 114 | marginBottom: 8, 115 | }, 116 | description: { 117 | fontSize: 14, 118 | color: "#666", 119 | marginBottom: 20, 120 | lineHeight: 20, 121 | }, 122 | scrollContainer: { 123 | flex: 1, 124 | }, 125 | section: { 126 | marginBottom: 24, 127 | }, 128 | subsectionTitle: { 129 | fontSize: 18, 130 | fontWeight: "600", 131 | marginBottom: 12, 132 | color: "#333", 133 | }, 134 | envItem: { 135 | backgroundColor: "rgba(0,0,0,0.03)", 136 | borderRadius: 8, 137 | padding: 12, 138 | marginBottom: 8, 139 | borderLeftWidth: 3, 140 | }, 141 | envHeader: { 142 | flexDirection: "row", 143 | alignItems: "center", 144 | marginBottom: 6, 145 | }, 146 | envName: { 147 | fontSize: 14, 148 | fontWeight: "600", 149 | marginLeft: 6, 150 | flex: 1, 151 | fontFamily: "monospace", 152 | }, 153 | badge: { 154 | paddingHorizontal: 6, 155 | paddingVertical: 2, 156 | borderRadius: 4, 157 | }, 158 | badgeText: { 159 | fontSize: 10, 160 | fontWeight: "bold", 161 | color: "#fff", 162 | }, 163 | envValue: { 164 | fontSize: 13, 165 | fontFamily: "monospace", 166 | backgroundColor: "rgba(0,0,0,0.05)", 167 | padding: 6, 168 | borderRadius: 4, 169 | color: "#333", 170 | }, 171 | emptyState: { 172 | alignItems: "center", 173 | padding: 20, 174 | backgroundColor: "rgba(0,0,0,0.03)", 175 | borderRadius: 8, 176 | }, 177 | emptyText: { 178 | fontSize: 14, 179 | color: "#666", 180 | textAlign: "center", 181 | marginTop: 8, 182 | lineHeight: 20, 183 | }, 184 | }); 185 | -------------------------------------------------------------------------------- /components/ExternalLink.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "expo-router"; 2 | import { openBrowserAsync } from "expo-web-browser"; 3 | import { type ComponentProps } from "react"; 4 | import { Platform } from "react-native"; 5 | 6 | type Props = Omit, "href"> & { href: string }; 7 | 8 | export function ExternalLink({ href, ...rest }: Props) { 9 | return ( 10 | { 15 | if (Platform.OS !== "web") { 16 | // Prevent the default behavior of linking to the default browser on native. 17 | event.preventDefault(); 18 | // Open the link in an in-app browser. 19 | await openBrowserAsync(href); 20 | } 21 | }} 22 | /> 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /components/HapticTab.tsx: -------------------------------------------------------------------------------- 1 | import { BottomTabBarButtonProps } from '@react-navigation/bottom-tabs'; 2 | import { PlatformPressable } from '@react-navigation/elements'; 3 | import * as Haptics from 'expo-haptics'; 4 | 5 | export function HapticTab(props: BottomTabBarButtonProps) { 6 | return ( 7 | { 10 | if (process.env.EXPO_OS === 'ios') { 11 | // Add a soft haptic feedback when pressing down on the tabs. 12 | Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); 13 | } 14 | props.onPressIn?.(ev); 15 | }} 16 | /> 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /components/HelloWave.tsx: -------------------------------------------------------------------------------- 1 | import { StyleSheet } from 'react-native'; 2 | import Animated, { 3 | useSharedValue, 4 | useAnimatedStyle, 5 | withTiming, 6 | withRepeat, 7 | withSequence, 8 | } from 'react-native-reanimated'; 9 | 10 | import { ThemedText } from '@/components/ThemedText'; 11 | 12 | export function HelloWave() { 13 | const rotationAnimation = useSharedValue(0); 14 | 15 | rotationAnimation.value = withRepeat( 16 | withSequence(withTiming(25, { duration: 150 }), withTiming(0, { duration: 150 })), 17 | 4 // Run the animation 4 times 18 | ); 19 | 20 | const animatedStyle = useAnimatedStyle(() => ({ 21 | transform: [{ rotate: `${rotationAnimation.value}deg` }], 22 | })); 23 | 24 | return ( 25 | 26 | 👋 27 | 28 | ); 29 | } 30 | 31 | const styles = StyleSheet.create({ 32 | text: { 33 | fontSize: 28, 34 | lineHeight: 32, 35 | marginTop: -6, 36 | }, 37 | }); 38 | -------------------------------------------------------------------------------- /components/ParallaxScrollView.tsx: -------------------------------------------------------------------------------- 1 | import type { PropsWithChildren, ReactElement } from 'react'; 2 | import { StyleSheet } from 'react-native'; 3 | import Animated, { 4 | interpolate, 5 | useAnimatedRef, 6 | useAnimatedStyle, 7 | useScrollViewOffset, 8 | } from 'react-native-reanimated'; 9 | 10 | import { ThemedView } from '@/components/ThemedView'; 11 | import { useBottomTabOverflow } from '@/components/ui/TabBarBackground'; 12 | import { useColorScheme } from '@/hooks/useColorScheme'; 13 | 14 | const HEADER_HEIGHT = 250; 15 | 16 | type Props = PropsWithChildren<{ 17 | headerImage: ReactElement; 18 | headerBackgroundColor: { dark: string; light: string }; 19 | }>; 20 | 21 | export default function ParallaxScrollView({ 22 | children, 23 | headerImage, 24 | headerBackgroundColor, 25 | }: Props) { 26 | const colorScheme = useColorScheme() ?? 'light'; 27 | const scrollRef = useAnimatedRef(); 28 | const scrollOffset = useScrollViewOffset(scrollRef); 29 | const bottom = useBottomTabOverflow(); 30 | const headerAnimatedStyle = useAnimatedStyle(() => { 31 | return { 32 | transform: [ 33 | { 34 | translateY: interpolate( 35 | scrollOffset.value, 36 | [-HEADER_HEIGHT, 0, HEADER_HEIGHT], 37 | [-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75] 38 | ), 39 | }, 40 | { 41 | scale: interpolate(scrollOffset.value, [-HEADER_HEIGHT, 0, HEADER_HEIGHT], [2, 1, 1]), 42 | }, 43 | ], 44 | }; 45 | }); 46 | 47 | return ( 48 | 49 | 54 | 60 | {headerImage} 61 | 62 | {children} 63 | 64 | 65 | ); 66 | } 67 | 68 | const styles = StyleSheet.create({ 69 | container: { 70 | flex: 1, 71 | }, 72 | header: { 73 | height: HEADER_HEIGHT, 74 | overflow: 'hidden', 75 | }, 76 | content: { 77 | flex: 1, 78 | padding: 32, 79 | gap: 16, 80 | overflow: 'hidden', 81 | }, 82 | }); 83 | -------------------------------------------------------------------------------- /components/StorageDemo.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { StyleSheet, TouchableOpacity, TextInput, Alert } from "react-native"; 3 | import { ThemedText } from "@/components/ThemedText"; 4 | import { ThemedView } from "@/components/ThemedView"; 5 | import { Ionicons } from "@expo/vector-icons"; 6 | import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; 7 | import AsyncStorage from "@react-native-async-storage/async-storage"; 8 | import * as SecureStore from "expo-secure-store"; 9 | import { storage } from "../storage/mmkv"; 10 | 11 | interface StorageItemProps { 12 | storageType: "mmkv" | "async" | "secure"; 13 | storageKey: string; 14 | title: string; 15 | icon: keyof typeof Ionicons.glyphMap; 16 | color: string; 17 | } 18 | 19 | const StorageItem: React.FC = ({ 20 | storageType, 21 | storageKey, 22 | title, 23 | icon, 24 | color, 25 | }) => { 26 | const queryClient = useQueryClient(); 27 | 28 | // Query to read from storage using the special storage query key format 29 | const { data: storedValue, isLoading } = useQuery({ 30 | queryKey: ["#storage", storageType, storageKey], 31 | queryFn: async () => { 32 | try { 33 | switch (storageType) { 34 | case "mmkv": 35 | // Use async method for mock MMKV 36 | return await storage.getStringAsync(storageKey); 37 | case "async": 38 | return await AsyncStorage.getItem(storageKey); 39 | case "secure": 40 | return await SecureStore.getItemAsync(storageKey); 41 | default: 42 | return null; 43 | } 44 | } catch (error) { 45 | console.error(`Error reading from ${storageType}:`, error); 46 | return null; 47 | } 48 | }, 49 | staleTime: 0, // Always refetch to show real-time updates 50 | }); 51 | 52 | return ( 53 | 54 | 55 | 56 | {title} 57 | 58 | 59 | 60 | Current Value 61 | 62 | {isLoading ? "Loading..." : storedValue || "No value stored"} 63 | 64 | 65 | 66 | ); 67 | }; 68 | 69 | const StorageInputCard: React.FC = () => { 70 | const [inputValue, setInputValue] = useState(""); 71 | const [selectedStorage, setSelectedStorage] = useState< 72 | "mmkv" | "async" | "secure" 73 | >("mmkv"); 74 | const queryClient = useQueryClient(); 75 | 76 | const storageOptions = [ 77 | { 78 | value: "mmkv", 79 | label: "MMKV Storage", 80 | key: "demo_mmkv_value", 81 | icon: "flash", 82 | color: "#8b5cf6", 83 | }, 84 | { 85 | value: "async", 86 | label: "AsyncStorage", 87 | key: "demo_async_value", 88 | icon: "server", 89 | color: "#06b6d4", 90 | }, 91 | { 92 | value: "secure", 93 | label: "SecureStore", 94 | key: "userToken", 95 | icon: "shield-checkmark", 96 | color: "#f59e0b", 97 | }, 98 | ]; 99 | 100 | const currentOption = storageOptions.find( 101 | (opt) => opt.value === selectedStorage 102 | )!; 103 | 104 | // Get current value for selected storage 105 | const { data: currentValue } = useQuery({ 106 | queryKey: ["#storage", selectedStorage, currentOption.key], 107 | queryFn: async () => { 108 | try { 109 | switch (selectedStorage) { 110 | case "mmkv": 111 | return await storage.getStringAsync(currentOption.key); 112 | case "async": 113 | return await AsyncStorage.getItem(currentOption.key); 114 | case "secure": 115 | return await SecureStore.getItemAsync(currentOption.key); 116 | default: 117 | return null; 118 | } 119 | } catch (error) { 120 | console.error(`Error reading from ${selectedStorage}:`, error); 121 | return null; 122 | } 123 | }, 124 | staleTime: 0, 125 | }); 126 | 127 | // Mutation to write to storage 128 | const writeMutation = useMutation({ 129 | mutationFn: async (value: string) => { 130 | switch (selectedStorage) { 131 | case "mmkv": 132 | await storage.setAsync(currentOption.key, value); 133 | break; 134 | case "async": 135 | await AsyncStorage.setItem(currentOption.key, value); 136 | break; 137 | case "secure": 138 | await SecureStore.setItemAsync(currentOption.key, value); 139 | break; 140 | } 141 | }, 142 | onSuccess: () => { 143 | queryClient.invalidateQueries({ 144 | queryKey: ["#storage", selectedStorage, currentOption.key], 145 | }); 146 | setInputValue(""); 147 | }, 148 | onError: (error) => { 149 | Alert.alert( 150 | "Error", 151 | `Failed to save to ${currentOption.label}: ${error.message}` 152 | ); 153 | }, 154 | }); 155 | 156 | // Mutation to delete from storage 157 | const deleteMutation = useMutation({ 158 | mutationFn: async () => { 159 | switch (selectedStorage) { 160 | case "mmkv": 161 | await storage.deleteAsync(currentOption.key); 162 | break; 163 | case "async": 164 | await AsyncStorage.removeItem(currentOption.key); 165 | break; 166 | case "secure": 167 | await SecureStore.deleteItemAsync(currentOption.key); 168 | break; 169 | } 170 | }, 171 | onSuccess: () => { 172 | queryClient.invalidateQueries({ 173 | queryKey: ["#storage", selectedStorage, currentOption.key], 174 | }); 175 | Alert.alert("Success", `Value deleted from ${currentOption.label}!`); 176 | }, 177 | onError: (error) => { 178 | Alert.alert( 179 | "Error", 180 | `Failed to delete from ${currentOption.label}: ${error.message}` 181 | ); 182 | }, 183 | }); 184 | 185 | const handleWrite = () => { 186 | if (inputValue.trim()) { 187 | writeMutation.mutate(inputValue.trim()); 188 | } 189 | }; 190 | 191 | const handleDelete = () => { 192 | Alert.alert( 193 | "Confirm Delete", 194 | `Are you sure you want to delete the value from ${currentOption.label}?`, 195 | [ 196 | { text: "Cancel", style: "cancel" }, 197 | { 198 | text: "Delete", 199 | style: "destructive", 200 | onPress: () => deleteMutation.mutate(), 201 | }, 202 | ] 203 | ); 204 | }; 205 | 206 | return ( 207 | 208 | 209 | 210 | Update Storage 211 | 212 | 213 | 214 | 215 | Select Storage Type 216 | 217 | 218 | {storageOptions.map((option) => ( 219 | setSelectedStorage(option.value as any)} 227 | > 228 | 233 | 240 | {option.label} 241 | 242 | 243 | ))} 244 | 245 | 246 | 247 | 248 | 255 | 256 | 266 | 272 | Save 273 | 274 | 284 | 290 | Delete 291 | 292 | 293 | 294 | 295 | ); 296 | }; 297 | 298 | export const StorageDemo: React.FC = () => { 299 | return ( 300 | 301 | Storage Demo 302 | 303 | Test storage sync with DevTools. Values will appear in real-time in the 304 | DevTools interface. 305 | {"\n\n"}Note: MMKV is mocked using AsyncStorage for Expo Go 306 | compatibility. 307 | 308 | 309 | 316 | 317 | 324 | 325 | 332 | 333 | 334 | 335 | ); 336 | }; 337 | 338 | const styles = StyleSheet.create({ 339 | container: { 340 | marginTop: 24, 341 | marginBottom: 16, 342 | }, 343 | sectionTitle: { 344 | fontSize: 24, 345 | fontWeight: "800", 346 | marginBottom: 6, 347 | textAlign: "center", 348 | }, 349 | description: { 350 | fontSize: 14, 351 | color: "#666", 352 | marginBottom: 20, 353 | lineHeight: 20, 354 | textAlign: "center", 355 | paddingHorizontal: 16, 356 | }, 357 | storageItem: { 358 | backgroundColor: "#fff", 359 | borderRadius: 12, 360 | padding: 12, 361 | marginBottom: 8, 362 | marginHorizontal: 4, 363 | shadowColor: "#000", 364 | shadowOffset: { 365 | width: 0, 366 | height: 2, 367 | }, 368 | shadowOpacity: 0.06, 369 | shadowRadius: 4, 370 | elevation: 2, 371 | borderWidth: 1, 372 | borderColor: "rgba(255,255,255,0.2)", 373 | }, 374 | storageHeader: { 375 | flexDirection: "row", 376 | alignItems: "center", 377 | marginBottom: 8, 378 | paddingBottom: 6, 379 | borderBottomWidth: 1, 380 | borderBottomColor: "rgba(0,0,0,0.06)", 381 | }, 382 | storageTitle: { 383 | fontSize: 14, 384 | fontWeight: "700", 385 | marginLeft: 8, 386 | color: "#1a1a1a", 387 | }, 388 | currentValue: { 389 | backgroundColor: "rgba(0,0,0,0.02)", 390 | borderRadius: 8, 391 | padding: 8, 392 | }, 393 | valueLabel: { 394 | fontSize: 10, 395 | color: "#666", 396 | marginBottom: 4, 397 | fontWeight: "600", 398 | textTransform: "uppercase", 399 | letterSpacing: 0.5, 400 | }, 401 | valueText: { 402 | fontSize: 12, 403 | fontFamily: "monospace", 404 | backgroundColor: "#f8f9fa", 405 | padding: 8, 406 | borderRadius: 6, 407 | borderWidth: 1, 408 | borderColor: "rgba(0,0,0,0.08)", 409 | color: "#2d3748", 410 | lineHeight: 16, 411 | }, 412 | inputContainer: { 413 | gap: 10, 414 | }, 415 | input: { 416 | height: 44, 417 | borderRadius: 12, 418 | paddingHorizontal: 16, 419 | fontSize: 14, 420 | backgroundColor: "#f8f9fa", 421 | borderWidth: 2, 422 | borderColor: "rgba(0,0,0,0.08)", 423 | color: "#2d3748", 424 | fontWeight: "500", 425 | }, 426 | buttonContainer: { 427 | flexDirection: "row", 428 | gap: 8, 429 | marginTop: 4, 430 | }, 431 | actionButton: { 432 | flex: 1, 433 | height: 40, 434 | borderRadius: 12, 435 | justifyContent: "center", 436 | alignItems: "center", 437 | flexDirection: "row", 438 | shadowColor: "#000", 439 | shadowOffset: { 440 | width: 0, 441 | height: 2, 442 | }, 443 | shadowOpacity: 0.1, 444 | shadowRadius: 4, 445 | elevation: 2, 446 | }, 447 | saveButton: { 448 | backgroundColor: "#10b981", 449 | }, 450 | deleteButton: { 451 | backgroundColor: "#ef4444", 452 | }, 453 | buttonText: { 454 | color: "#fff", 455 | fontSize: 14, 456 | fontWeight: "700", 457 | marginLeft: 6, 458 | }, 459 | buttonIcon: { 460 | marginRight: 2, 461 | }, 462 | disabledButton: { 463 | opacity: 0.6, 464 | shadowOpacity: 0.05, 465 | }, 466 | inputCard: { 467 | backgroundColor: "#fff", 468 | borderRadius: 16, 469 | padding: 16, 470 | marginTop: 20, 471 | marginBottom: 12, 472 | marginHorizontal: 4, 473 | shadowColor: "#000", 474 | shadowOffset: { 475 | width: 0, 476 | height: 4, 477 | }, 478 | shadowOpacity: 0.08, 479 | shadowRadius: 8, 480 | elevation: 4, 481 | borderWidth: 1, 482 | borderColor: "rgba(255,255,255,0.2)", 483 | }, 484 | inputCardHeader: { 485 | flexDirection: "row", 486 | alignItems: "center", 487 | marginBottom: 12, 488 | paddingBottom: 8, 489 | borderBottomWidth: 1, 490 | borderBottomColor: "rgba(0,0,0,0.06)", 491 | }, 492 | inputCardTitle: { 493 | fontSize: 16, 494 | fontWeight: "700", 495 | marginLeft: 10, 496 | color: "#1a1a1a", 497 | }, 498 | dropdownContainer: { 499 | marginBottom: 16, 500 | }, 501 | dropdownLabel: { 502 | fontSize: 12, 503 | color: "#666", 504 | marginBottom: 6, 505 | fontWeight: "600", 506 | textTransform: "uppercase", 507 | letterSpacing: 0.5, 508 | }, 509 | dropdown: { 510 | backgroundColor: "#f8f9fa", 511 | borderRadius: 12, 512 | padding: 8, 513 | }, 514 | dropdownOption: { 515 | flexDirection: "row", 516 | alignItems: "center", 517 | padding: 8, 518 | borderRadius: 8, 519 | }, 520 | dropdownOptionSelected: { 521 | backgroundColor: "rgba(79, 70, 229, 0.1)", 522 | }, 523 | dropdownOptionText: { 524 | fontSize: 14, 525 | color: "#666", 526 | marginLeft: 8, 527 | }, 528 | dropdownOptionTextSelected: { 529 | color: "#4f46e5", 530 | fontWeight: "700", 531 | }, 532 | inputSection: { 533 | gap: 10, 534 | }, 535 | }); 536 | -------------------------------------------------------------------------------- /components/ThemedText.tsx: -------------------------------------------------------------------------------- 1 | import { Text, type TextProps, StyleSheet } from 'react-native'; 2 | 3 | import { useThemeColor } from '@/hooks/useThemeColor'; 4 | 5 | export type ThemedTextProps = TextProps & { 6 | lightColor?: string; 7 | darkColor?: string; 8 | type?: 'default' | 'title' | 'defaultSemiBold' | 'subtitle' | 'link'; 9 | }; 10 | 11 | export function ThemedText({ 12 | style, 13 | lightColor, 14 | darkColor, 15 | type = 'default', 16 | ...rest 17 | }: ThemedTextProps) { 18 | const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text'); 19 | 20 | return ( 21 | 33 | ); 34 | } 35 | 36 | const styles = StyleSheet.create({ 37 | default: { 38 | fontSize: 16, 39 | lineHeight: 24, 40 | }, 41 | defaultSemiBold: { 42 | fontSize: 16, 43 | lineHeight: 24, 44 | fontWeight: '600', 45 | }, 46 | title: { 47 | fontSize: 32, 48 | fontWeight: 'bold', 49 | lineHeight: 32, 50 | }, 51 | subtitle: { 52 | fontSize: 20, 53 | fontWeight: 'bold', 54 | }, 55 | link: { 56 | lineHeight: 30, 57 | fontSize: 16, 58 | color: '#0a7ea4', 59 | }, 60 | }); 61 | -------------------------------------------------------------------------------- /components/ThemedView.tsx: -------------------------------------------------------------------------------- 1 | import { View, type ViewProps } from 'react-native'; 2 | 3 | import { useThemeColor } from '@/hooks/useThemeColor'; 4 | 5 | export type ThemedViewProps = ViewProps & { 6 | lightColor?: string; 7 | darkColor?: string; 8 | }; 9 | 10 | export function ThemedView({ style, lightColor, darkColor, ...otherProps }: ThemedViewProps) { 11 | const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background'); 12 | 13 | return ; 14 | } 15 | -------------------------------------------------------------------------------- /components/__tests__/ThemedText-test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import renderer from 'react-test-renderer'; 3 | 4 | import { ThemedText } from '../ThemedText'; 5 | 6 | it(`renders correctly`, () => { 7 | const tree = renderer.create(Snapshot test!).toJSON(); 8 | 9 | expect(tree).toMatchSnapshot(); 10 | }); 11 | -------------------------------------------------------------------------------- /components/__tests__/__snapshots__/ThemedText-test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`renders correctly 1`] = ` 4 | 22 | Snapshot test! 23 | 24 | `; 25 | -------------------------------------------------------------------------------- /components/ui/IconSymbol.ios.tsx: -------------------------------------------------------------------------------- 1 | import { SymbolView, SymbolViewProps, SymbolWeight } from 'expo-symbols'; 2 | import { StyleProp, ViewStyle } from 'react-native'; 3 | 4 | export function IconSymbol({ 5 | name, 6 | size = 24, 7 | color, 8 | style, 9 | weight = 'regular', 10 | }: { 11 | name: SymbolViewProps['name']; 12 | size?: number; 13 | color: string; 14 | style?: StyleProp; 15 | weight?: SymbolWeight; 16 | }) { 17 | return ( 18 | 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /components/ui/IconSymbol.tsx: -------------------------------------------------------------------------------- 1 | // This file is a fallback for using MaterialIcons on Android and web. 2 | 3 | import MaterialIcons from "@expo/vector-icons/MaterialIcons"; 4 | import { SymbolWeight } from "expo-symbols"; 5 | import React from "react"; 6 | import { OpaqueColorValue, StyleProp, TextStyle } from "react-native"; 7 | 8 | // Add your SFSymbol to MaterialIcons mappings here. 9 | const MAPPING = { 10 | // See MaterialIcons here: https://icons.expo.fyi 11 | // See SF Symbols in the SF Symbols app on Mac. 12 | "house.fill": "home", 13 | "paperplane.fill": "send", 14 | "chevron.left.forwardslash.chevron.right": "code", 15 | "chevron.right": "chevron-right", 16 | } as Partial< 17 | Record< 18 | import("expo-symbols").SymbolViewProps["name"], 19 | React.ComponentProps["name"] 20 | > 21 | >; 22 | 23 | export type IconSymbolName = keyof typeof MAPPING; 24 | 25 | /** 26 | * An icon component that uses native SFSymbols on iOS, and MaterialIcons on Android and web. This ensures a consistent look across platforms, and optimal resource usage. 27 | * 28 | * Icon `name`s are based on SFSymbols and require manual mapping to MaterialIcons. 29 | */ 30 | export function IconSymbol({ 31 | name, 32 | size = 24, 33 | color, 34 | style, 35 | }: { 36 | name: IconSymbolName; 37 | size?: number; 38 | color: string | OpaqueColorValue; 39 | style?: StyleProp; 40 | weight?: SymbolWeight; 41 | }) { 42 | return ( 43 | 49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /components/ui/TabBarBackground.ios.tsx: -------------------------------------------------------------------------------- 1 | import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs'; 2 | import { BlurView } from 'expo-blur'; 3 | import { StyleSheet } from 'react-native'; 4 | import { useSafeAreaInsets } from 'react-native-safe-area-context'; 5 | 6 | export default function BlurTabBarBackground() { 7 | return ( 8 | 15 | ); 16 | } 17 | 18 | export function useBottomTabOverflow() { 19 | const tabHeight = useBottomTabBarHeight(); 20 | const { bottom } = useSafeAreaInsets(); 21 | return tabHeight - bottom; 22 | } 23 | -------------------------------------------------------------------------------- /components/ui/TabBarBackground.tsx: -------------------------------------------------------------------------------- 1 | // This is a shim for web and Android where the tab bar is generally opaque. 2 | export default undefined; 3 | 4 | export function useBottomTabOverflow() { 5 | return 0; 6 | } 7 | -------------------------------------------------------------------------------- /constants/Colors.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Below are the colors that are used in the app. The colors are defined in the light and dark mode. 3 | * There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc. 4 | */ 5 | 6 | const tintColorLight = '#0a7ea4'; 7 | const tintColorDark = '#fff'; 8 | 9 | export const Colors = { 10 | light: { 11 | text: '#11181C', 12 | background: '#fff', 13 | tint: tintColorLight, 14 | icon: '#687076', 15 | tabIconDefault: '#687076', 16 | tabIconSelected: tintColorLight, 17 | }, 18 | dark: { 19 | text: '#ECEDEE', 20 | background: '#151718', 21 | tint: tintColorDark, 22 | icon: '#9BA1A6', 23 | tabIconDefault: '#9BA1A6', 24 | tabIconSelected: tintColorDark, 25 | }, 26 | }; 27 | -------------------------------------------------------------------------------- /hooks/useColorScheme.ts: -------------------------------------------------------------------------------- 1 | export { useColorScheme } from 'react-native'; 2 | -------------------------------------------------------------------------------- /hooks/useColorScheme.web.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { useColorScheme as useRNColorScheme } from 'react-native'; 3 | 4 | /** 5 | * To support static rendering, this value needs to be re-calculated on the client side for web 6 | */ 7 | export function useColorScheme() { 8 | const [hasHydrated, setHasHydrated] = useState(false); 9 | 10 | useEffect(() => { 11 | setHasHydrated(true); 12 | }, []); 13 | 14 | const colorScheme = useRNColorScheme(); 15 | 16 | if (hasHydrated) { 17 | return colorScheme; 18 | } 19 | 20 | return 'light'; 21 | } 22 | -------------------------------------------------------------------------------- /hooks/useThemeColor.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Learn more about light and dark modes: 3 | * https://docs.expo.dev/guides/color-schemes/ 4 | */ 5 | 6 | import { Colors } from '@/constants/Colors'; 7 | import { useColorScheme } from '@/hooks/useColorScheme'; 8 | 9 | export function useThemeColor( 10 | props: { light?: string; dark?: string }, 11 | colorName: keyof typeof Colors.light & keyof typeof Colors.dark 12 | ) { 13 | const theme = useColorScheme() ?? 'light'; 14 | const colorFromProps = props[theme]; 15 | 16 | if (colorFromProps) { 17 | return colorFromProps; 18 | } else { 19 | return Colors[theme][colorName]; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rn-dev-tools-exmaple", 3 | "main": "expo-router/entry", 4 | "version": "1.0.0", 5 | "scripts": { 6 | "start": "expo start", 7 | "reset-project": "node ./scripts/reset-project.js", 8 | "android": "expo run:android", 9 | "ios": "expo run:ios", 10 | "web": "expo start --web", 11 | "test": "jest --watchAll", 12 | "rd": "rm -rf node_modules/react-native-react-query-devtools && npm uninstall react-native-react-query-devtools && npm install ./react-native-react-query-devtools-1.3.8.tgz", 13 | "lint": "expo lint" 14 | }, 15 | "jest": { 16 | "preset": "jest-expo" 17 | }, 18 | "dependencies": { 19 | "@expo/vector-icons": "^14.1.0", 20 | "@react-native-async-storage/async-storage": "^2.1.2", 21 | "@react-navigation/bottom-tabs": "^7.0.0", 22 | "@react-navigation/native": "^7.0.0", 23 | "@tanstack/react-query": "^5.62.0", 24 | "expo": "^53.0.0", 25 | "expo-blur": "~14.1.4", 26 | "expo-clipboard": "~7.1.4", 27 | "expo-constants": "~17.1.6", 28 | "expo-font": "~13.3.1", 29 | "expo-haptics": "~14.1.4", 30 | "expo-linking": "~7.1.5", 31 | "expo-router": "~5.0.7", 32 | "expo-secure-store": "^14.2.3", 33 | "expo-splash-screen": "~0.30.8", 34 | "expo-status-bar": "~2.2.3", 35 | "expo-symbols": "~0.4.4", 36 | "expo-system-ui": "~5.0.7", 37 | "expo-web-browser": "~14.1.6", 38 | "i": "^0.3.7", 39 | "npm": "^11.2.0", 40 | "react": "19.0.0", 41 | "react-dom": "19.0.0", 42 | "react-native": "0.79.2", 43 | "react-native-gesture-handler": "~2.24.0", 44 | "react-native-reanimated": "~3.17.4", 45 | "react-native-safe-area-context": "5.4.0", 46 | "react-native-screens": "~4.10.0", 47 | "react-native-svg": "^15.11.2", 48 | "react-native-web": "^0.20.0", 49 | "react-native-webview": "13.13.5", 50 | "tanstack-query-dev-tools-expo-plugin": "^0.1.1" 51 | }, 52 | "devDependencies": { 53 | "@babel/core": "^7.25.2", 54 | "@types/jest": "^29.5.12", 55 | "@types/react": "~19.0.10", 56 | "jest": "^29.2.1", 57 | "jest-expo": "~53.0.5", 58 | "react-query-external-sync": "^2.1.0", 59 | "socket.io-client": "^4.8.1", 60 | "typescript": "~5.8.3" 61 | }, 62 | "private": true 63 | } 64 | -------------------------------------------------------------------------------- /scripts/reset-project.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * This script is used to reset the project to a blank state. 5 | * It moves the /app, /components, /hooks, /scripts, and /constants directories to /app-example and creates a new /app directory with an index.tsx and _layout.tsx file. 6 | * You can remove the `reset-project` script from package.json and safely delete this file after running it. 7 | */ 8 | 9 | const fs = require("fs"); 10 | const path = require("path"); 11 | 12 | const root = process.cwd(); 13 | const oldDirs = ["app", "components", "hooks", "constants", "scripts"]; 14 | const newDir = "app-example"; 15 | const newAppDir = "app"; 16 | const newDirPath = path.join(root, newDir); 17 | 18 | const indexContent = `import { Text, View } from "react-native"; 19 | 20 | export default function Index() { 21 | return ( 22 | 29 | Edit app/index.tsx to edit this screen. 30 | 31 | ); 32 | } 33 | `; 34 | 35 | const layoutContent = `import { Stack } from "expo-router"; 36 | 37 | export default function RootLayout() { 38 | return ; 39 | } 40 | `; 41 | 42 | const moveDirectories = async () => { 43 | try { 44 | // Create the app-example directory 45 | await fs.promises.mkdir(newDirPath, { recursive: true }); 46 | console.log(`📁 /${newDir} directory created.`); 47 | 48 | // Move old directories to new app-example directory 49 | for (const dir of oldDirs) { 50 | const oldDirPath = path.join(root, dir); 51 | const newDirPath = path.join(root, newDir, dir); 52 | if (fs.existsSync(oldDirPath)) { 53 | await fs.promises.rename(oldDirPath, newDirPath); 54 | console.log(`➡️ /${dir} moved to /${newDir}/${dir}.`); 55 | } else { 56 | console.log(`➡️ /${dir} does not exist, skipping.`); 57 | } 58 | } 59 | 60 | // Create new /app directory 61 | const newAppDirPath = path.join(root, newAppDir); 62 | await fs.promises.mkdir(newAppDirPath, { recursive: true }); 63 | console.log("\n📁 New /app directory created."); 64 | 65 | // Create index.tsx 66 | const indexPath = path.join(newAppDirPath, "index.tsx"); 67 | await fs.promises.writeFile(indexPath, indexContent); 68 | console.log("📄 app/index.tsx created."); 69 | 70 | // Create _layout.tsx 71 | const layoutPath = path.join(newAppDirPath, "_layout.tsx"); 72 | await fs.promises.writeFile(layoutPath, layoutContent); 73 | console.log("📄 app/_layout.tsx created."); 74 | 75 | console.log("\n✅ Project reset complete. Next steps:"); 76 | console.log( 77 | "1. Run `npx expo start` to start a development server.\n2. Edit app/index.tsx to edit the main screen.\n3. Delete the /app-example directory when you're done referencing it." 78 | ); 79 | } catch (error) { 80 | console.error(`Error during script execution: ${error}`); 81 | } 82 | }; 83 | 84 | moveDirectories(); 85 | -------------------------------------------------------------------------------- /storage/mmkv.ts: -------------------------------------------------------------------------------- 1 | import AsyncStorage from "@react-native-async-storage/async-storage"; 2 | 3 | // Mock MMKV implementation for Expo Go compatibility 4 | // In a real app with development builds, you would use the actual MMKV package 5 | 6 | class MockMMKV { 7 | private id: string; 8 | private encryptionKey?: string; 9 | 10 | constructor(config: { id: string; encryptionKey?: string }) { 11 | this.id = config.id; 12 | this.encryptionKey = config.encryptionKey; 13 | } 14 | 15 | private getKey(key: string): string { 16 | return `mmkv_${this.id}_${key}`; 17 | } 18 | 19 | set(key: string, value: string | number | boolean): void { 20 | const storageKey = this.getKey(key); 21 | const stringValue = 22 | typeof value === "string" ? value : JSON.stringify(value); 23 | AsyncStorage.setItem(storageKey, stringValue).catch(console.error); 24 | } 25 | 26 | async setAsync(key: string, value: string | number | boolean): Promise { 27 | const storageKey = this.getKey(key); 28 | const stringValue = 29 | typeof value === "string" ? value : JSON.stringify(value); 30 | await AsyncStorage.setItem(storageKey, stringValue); 31 | } 32 | 33 | getString(key: string): string | undefined { 34 | // Note: This is synchronous in real MMKV, but async in our mock 35 | // For demo purposes, we'll return undefined and handle async in the components 36 | return undefined; 37 | } 38 | 39 | async getStringAsync(key: string): Promise { 40 | const storageKey = this.getKey(key); 41 | return await AsyncStorage.getItem(storageKey); 42 | } 43 | 44 | getNumber(key: string): number | undefined { 45 | return undefined; 46 | } 47 | 48 | async getNumberAsync(key: string): Promise { 49 | const storageKey = this.getKey(key); 50 | const value = await AsyncStorage.getItem(storageKey); 51 | return value ? parseFloat(value) : null; 52 | } 53 | 54 | getBoolean(key: string): boolean | undefined { 55 | return undefined; 56 | } 57 | 58 | async getBooleanAsync(key: string): Promise { 59 | const storageKey = this.getKey(key); 60 | const value = await AsyncStorage.getItem(storageKey); 61 | return value ? JSON.parse(value) : null; 62 | } 63 | 64 | delete(key: string): void { 65 | const storageKey = this.getKey(key); 66 | AsyncStorage.removeItem(storageKey).catch(console.error); 67 | } 68 | 69 | async deleteAsync(key: string): Promise { 70 | const storageKey = this.getKey(key); 71 | await AsyncStorage.removeItem(storageKey); 72 | } 73 | 74 | getAllKeys(): string[] { 75 | // In real MMKV this is synchronous, but we'll need to handle this async 76 | return []; 77 | } 78 | 79 | async getAllKeysAsync(): Promise { 80 | const allKeys = await AsyncStorage.getAllKeys(); 81 | const prefix = `mmkv_${this.id}_`; 82 | return allKeys 83 | .filter((key) => key.startsWith(prefix)) 84 | .map((key) => key.replace(prefix, "")); 85 | } 86 | 87 | clearAll(): void { 88 | this.getAllKeysAsync() 89 | .then((keys) => { 90 | keys.forEach((key) => this.delete(key)); 91 | }) 92 | .catch(console.error); 93 | } 94 | } 95 | 96 | // Create mock MMKV storage instance 97 | export const storage = new MockMMKV({ 98 | id: "rn-dev-tools-example", 99 | encryptionKey: "demo-encryption-key", // In production, use a secure key 100 | }); 101 | 102 | // Helper functions for easier usage with async operations 103 | export const mmkvStorage = { 104 | setItem: async (key: string, value: string): Promise => { 105 | await storage.setAsync(key, value); 106 | }, 107 | getItem: async (key: string): Promise => { 108 | return await storage.getStringAsync(key); 109 | }, 110 | removeItem: async (key: string): Promise => { 111 | await storage.deleteAsync(key); 112 | }, 113 | clear: () => { 114 | storage.clearAll(); 115 | }, 116 | getAllKeys: async (): Promise => { 117 | return await storage.getAllKeysAsync(); 118 | }, 119 | }; 120 | 121 | export default storage; 122 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "expo/tsconfig.base", 3 | "compilerOptions": { 4 | "strict": true, 5 | "paths": { 6 | "@/*": [ 7 | "./*" 8 | ] 9 | } 10 | }, 11 | "include": [ 12 | "**/*.ts", 13 | "**/*.tsx", 14 | ".expo/types/**/*.ts", 15 | "expo-env.d.ts" 16 | ] 17 | } 18 | --------------------------------------------------------------------------------