react-native-inapp-inspector 2.3.0 → 2.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -255,34 +255,38 @@ export const calculateByteSize = (str) => {
255
255
  return str.length * 2;
256
256
  };
257
257
  /**
258
- * Determine type and format value safely
258
+ * Determine type and format value safely without blocking JS thread
259
259
  */
260
260
  export const analyzeStorageValue = (rawVal) => {
261
261
  if (rawVal === null || rawVal === undefined) {
262
262
  return { strVal: 'null', type: 'null', byteSize: 4 };
263
263
  }
264
264
  const strVal = typeof rawVal === 'string' ? rawVal : String(rawVal);
265
- const byteSize = calculateByteSize(strVal);
265
+ const byteSize = strVal.length * 2;
266
266
  if (typeof rawVal === 'number') {
267
- return { strVal, parsedVal: rawVal, type: 'number', byteSize };
267
+ return { strVal, parsedValue: rawVal, type: 'number', byteSize };
268
268
  }
269
269
  if (typeof rawVal === 'boolean') {
270
- return { strVal, parsedVal: rawVal, type: 'boolean', byteSize };
270
+ return { strVal, parsedValue: rawVal, type: 'boolean', byteSize };
271
271
  }
272
272
  const trimmed = strVal.trim();
273
273
  if ((trimmed.startsWith('{') && trimmed.endsWith('}')) ||
274
274
  (trimmed.startsWith('[') && trimmed.endsWith(']'))) {
275
- try {
276
- const parsed = JSON.parse(trimmed);
277
- return { strVal, parsedVal: parsed, type: 'json', byteSize };
275
+ // For small/medium JSON, parse eagerly; for huge payloads (>10KB), parse lazily on expand
276
+ if (trimmed.length < 10000) {
277
+ try {
278
+ const parsed = JSON.parse(trimmed);
279
+ return { strVal, parsedValue: parsed, type: 'json', byteSize };
280
+ }
281
+ catch { }
278
282
  }
279
- catch { }
283
+ return { strVal, type: 'json', byteSize };
280
284
  }
281
285
  if (trimmed === 'true' || trimmed === 'false') {
282
- return { strVal, parsedVal: trimmed === 'true', type: 'boolean', byteSize };
286
+ return { strVal, parsedValue: trimmed === 'true', type: 'boolean', byteSize };
283
287
  }
284
288
  if (!isNaN(Number(trimmed)) && trimmed !== '') {
285
- return { strVal, parsedVal: Number(trimmed), type: 'number', byteSize };
289
+ return { strVal, parsedValue: Number(trimmed), type: 'number', byteSize };
286
290
  }
287
291
  return { strVal, type: 'string', byteSize };
288
292
  };
@@ -307,7 +311,7 @@ export const fetchStorageEntries = async (driver, instanceId) => {
307
311
  entries.push({
308
312
  key: k,
309
313
  value: analysis.strVal,
310
- parsedValue: analysis.parsedVal,
314
+ parsedValue: analysis.parsedValue,
311
315
  type: analysis.type,
312
316
  byteSize: analysis.byteSize,
313
317
  });
@@ -320,7 +324,7 @@ export const fetchStorageEntries = async (driver, instanceId) => {
320
324
  entries.push({
321
325
  key: k,
322
326
  value: analysis.strVal,
323
- parsedValue: analysis.parsedVal,
327
+ parsedValue: analysis.parsedValue,
324
328
  type: analysis.type,
325
329
  byteSize: analysis.byteSize,
326
330
  });
@@ -361,7 +365,7 @@ export const fetchStorageEntries = async (driver, instanceId) => {
361
365
  entries.push({
362
366
  key: k,
363
367
  value: analysis.strVal,
364
- parsedValue: analysis.parsedVal,
368
+ parsedValue: analysis.parsedValue,
365
369
  type: analysis.type,
366
370
  byteSize: analysis.byteSize,
367
371
  });
package/dist/esm/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react';
2
- import { Alert, Animated, PanResponder, Platform, UIManager, LogBox, InteractionManager, } from 'react-native';
2
+ import { Alert, Animated, PanResponder, Platform, UIManager, LogBox, } from 'react-native';
3
3
  import { NavigationContext } from '@react-navigation/native';
4
4
  // i18n
5
5
  import { I18nextProvider, i18n } from './i18n';
@@ -27,7 +27,7 @@ import { showNativeFloatingButton, hideNativeFloatingButton, setNativeFloatingBu
27
27
  import { LIB_VERSION } from './constants';
28
28
  // Stylesheet
29
29
  import { toggleGlobalTheme } from './styles';
30
- const NetworkInspector = ({ enabled = true, isEnabled = true, storage, navigationRef, appIcon, environment, initialVisible, visible: controlledVisible, }) => {
30
+ const NetworkInspector = ({ enabled = true, storage, navigationRef, appIcon, environment, initialVisible = false, visible: controlledVisible, }) => {
31
31
  // Set custom storage synchronously during render phase
32
32
  setCustomStorage(storage || null);
33
33
  const [isDark, setIsDark] = useState(false);
@@ -39,7 +39,7 @@ const NetworkInspector = ({ enabled = true, isEnabled = true, storage, navigatio
39
39
  const [modalHeightPercent, setModalHeightPercent] = useState(90);
40
40
  const [modalAnimationType, setModalAnimationType] = useState('slide');
41
41
  const [logs, setLogs] = useState([]);
42
- const [visible, setVisible] = useState(initialVisible ?? controlledVisible ?? false);
42
+ const [visible, setVisible] = useState(controlledVisible ?? initialVisible ?? false);
43
43
  useEffect(() => {
44
44
  if (controlledVisible !== undefined) {
45
45
  setVisible(controlledVisible);
@@ -743,22 +743,26 @@ const NetworkInspector = ({ enabled = true, isEnabled = true, storage, navigatio
743
743
  const useNativeFab = (Platform.OS === 'ios' || Platform.OS === 'android') && isNativeModule;
744
744
  // 100% Native Main-Thread Floating Button Lifecycle
745
745
  useEffect(() => {
746
- if (!useNativeFab || !isEnabled || !enabled) {
746
+ if (!useNativeFab || !enabled) {
747
747
  if (useNativeFab) {
748
- hideNativeFloatingButton();
748
+ hideNativeFloatingButton().catch(() => { });
749
749
  }
750
750
  return;
751
751
  }
752
752
  if (visible) {
753
- hideNativeFloatingButton();
753
+ hideNativeFloatingButton().catch(() => { });
754
754
  }
755
755
  else {
756
- showNativeFloatingButton();
757
- setNativeFloatingButtonBadge(logs.length > 0 || analyticsEvents.length > 0);
756
+ showNativeFloatingButton().catch(() => { });
757
+ setNativeFloatingButtonBadge(logs.length > 0 || analyticsEvents.length > 0).catch(() => { });
758
758
  }
759
+ return () => {
760
+ if (useNativeFab) {
761
+ hideNativeFloatingButton().catch(() => { });
762
+ }
763
+ };
759
764
  }, [
760
765
  useNativeFab,
761
- isEnabled,
762
766
  enabled,
763
767
  visible,
764
768
  logs.length,
@@ -817,20 +821,11 @@ const NetworkInspector = ({ enabled = true, isEnabled = true, storage, navigatio
817
821
  if (freshCrashes.length > 0) {
818
822
  setCrashRecords(freshCrashes);
819
823
  }
820
- }
821
- // eslint-disable-next-line react-hooks/exhaustive-deps
822
- }, [visible]);
823
- useEffect(() => {
824
- if (visible) {
825
- const task = InteractionManager.runAfterInteractions(() => {
824
+ const frame = requestAnimationFrame(() => {
826
825
  setIsReady(true);
827
826
  });
828
- const fallbackTimer = setTimeout(() => {
829
- setIsReady(true);
830
- }, 350);
831
827
  return () => {
832
- task.cancel();
833
- clearTimeout(fallbackTimer);
828
+ cancelAnimationFrame(frame);
834
829
  };
835
830
  }
836
831
  else {
@@ -1666,7 +1661,8 @@ const NetworkInspector = ({ enabled = true, isEnabled = true, storage, navigatio
1666
1661
  setVisible,
1667
1662
  closeModal,
1668
1663
  isReady,
1669
- isEnabled,
1664
+ enabled,
1665
+ isEnabled: enabled,
1670
1666
  appIcon,
1671
1667
  environment,
1672
1668
  modalHeightPercent,
@@ -1843,14 +1839,22 @@ const NetworkInspector = ({ enabled = true, isEnabled = true, storage, navigatio
1843
1839
  </InspectorContext.Provider>);
1844
1840
  };
1845
1841
  const NetworkInspectorWrapper = (props) => {
1846
- // If running in production release build and not explicitly force-enabled (e.g. for QA builds),
1847
- // return null immediately for 0-overhead production stubbing.
1848
- if (typeof __DEV__ !== 'undefined' && !__DEV__ && !props?.forceEnable) {
1842
+ const enabled = props?.enabled ?? true;
1843
+ useEffect(() => {
1844
+ if (!enabled) {
1845
+ hideNativeFloatingButton().catch(() => { });
1846
+ }
1847
+ return () => {
1848
+ hideNativeFloatingButton().catch(() => { });
1849
+ };
1850
+ }, [enabled]);
1851
+ // If enabled is false, return null immediately with 0 overhead
1852
+ if (!enabled) {
1849
1853
  return null;
1850
1854
  }
1851
1855
  return (<I18nextProvider i18n={i18n}>
1852
1856
  <ErrorBoundary fallbackType="inline">
1853
- <NetworkInspector {...props}/>
1857
+ <NetworkInspector {...props} enabled={enabled}/>
1854
1858
  </ErrorBoundary>
1855
1859
  </I18nextProvider>);
1856
1860
  };
@@ -157,8 +157,6 @@ export interface PersistedSettings {
157
157
  }
158
158
  export interface NetworkInspectorProps {
159
159
  enabled?: boolean;
160
- isEnabled?: boolean;
161
- forceEnable?: boolean;
162
160
  storage?: InspectorStorage;
163
161
  navigationRef?: any;
164
162
  appIcon?: any;
@@ -174,6 +172,7 @@ export interface InspectorContextValue {
174
172
  setVisible: React.Dispatch<React.SetStateAction<boolean>>;
175
173
  closeModal: () => void;
176
174
  isReady: boolean;
175
+ enabled: boolean;
177
176
  isEnabled: boolean;
178
177
  appIcon?: any;
179
178
  environment?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-inapp-inspector",
3
- "version": "2.3.0",
3
+ "version": "2.3.2",
4
4
  "description": "The zero-config, all-in-one in-app debugger for React Native & Expo. Inspect Network (fetch/axios), Console logs, Stack Traces, Redux State, Firebase Analytics, and JS Bundle size directly on device.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -21,6 +21,7 @@ import LogDetail from './LogDetail';
21
21
  import ConsoleTab from './ConsoleTab';
22
22
  import AnalyticsTab from './AnalyticsTab';
23
23
  import AnalyticsDetail from '../AnalyticsDetail';
24
+ import SkeletonPlaceholder from '../SkeletonPlaceholder';
24
25
  import ReduxTab from './ReduxTab';
25
26
  import ReduxDetail from './ReduxDetail';
26
27
  import BundleTab from './BundleTab';
@@ -51,7 +52,7 @@ const MainScreen = () => {
51
52
  settingsPage,
52
53
  activeTab,
53
54
  isReady,
54
- isEnabled,
55
+ enabled,
55
56
  useNativeFab,
56
57
  hasNavigationContext,
57
58
  setNavState,
@@ -104,7 +105,7 @@ const MainScreen = () => {
104
105
  return (
105
106
  <>
106
107
  {(Platform.OS === 'ios' || Platform.OS === 'android') &&
107
- isEnabled &&
108
+ enabled &&
108
109
  !visible &&
109
110
  !useNativeFab && <FabLauncher />}
110
111
  <Modal
@@ -137,11 +138,11 @@ const MainScreen = () => {
137
138
  <InspectorHeader />
138
139
 
139
140
  <View style={{flex: 1}}>
141
+ {/* ─── Horizontal Scrollable Tab Bar inside Content (Always visible) ─── */}
142
+ {!isDetailActive && <TabBar />}
143
+
140
144
  {isReady ? (
141
145
  <View style={{flex: 1}}>
142
- {/* ─── Horizontal Scrollable Tab Bar inside Content ─── */}
143
- {!isDetailActive && <TabBar />}
144
-
145
146
  {/* Persistent List Layer - Never unmounted, preserves 100% native scroll with smooth tab transition */}
146
147
  <Animated.View
147
148
  style={[
@@ -207,12 +208,7 @@ const MainScreen = () => {
207
208
  )}
208
209
  </View>
209
210
  ) : (
210
- <View style={styles.empty}>
211
- <ActivityIndicator size="large" color={AppColors.purple} />
212
- <Text style={[styles.emptySub, {marginTop: 12}]}>
213
- Loading logs...
214
- </Text>
215
- </View>
211
+ <SkeletonPlaceholder />
216
212
  )}
217
213
 
218
214
  {/* Settings Panel Layer - Rendered on top with smooth slide & spring transition */}
@@ -3,6 +3,7 @@ import {
3
3
  View,
4
4
  Text,
5
5
  ScrollView,
6
+ FlatList,
6
7
  TextInput,
7
8
  Modal,
8
9
  Alert,
@@ -43,6 +44,131 @@ import {
43
44
  import {copyToClipboard} from '../../helpers';
44
45
  import {showToast} from '../../helpers/toast';
45
46
 
47
+ const getTypeBadge = (type: StorageEntry['type']) => {
48
+ switch (type) {
49
+ case 'json':
50
+ return {label: 'JSON', color: AppColors.purple, bg: `${AppColors.purple}16`};
51
+ case 'number':
52
+ return {label: 'NUM', color: AppColors.warningIconGold, bg: `${AppColors.warningIconGold}16`};
53
+ case 'boolean':
54
+ return {label: 'BOOL', color: AppColors.emerald500, bg: `${AppColors.emerald500}16`};
55
+ case 'null':
56
+ return {label: 'NULL', color: AppColors.grayTextWeak, bg: `${AppColors.grayTextWeak}16`};
57
+ default:
58
+ return {label: 'STR', color: AppColors.blue500, bg: `${AppColors.blue500}16`};
59
+ }
60
+ };
61
+
62
+ const StorageEntryCard = React.memo(function StorageEntryCard({
63
+ entry,
64
+ isExpanded,
65
+ onToggleExpand,
66
+ onCopy,
67
+ onEdit,
68
+ onDelete,
69
+ badge,
70
+ }: {
71
+ entry: StorageEntry;
72
+ isExpanded: boolean;
73
+ onToggleExpand: (key: string) => void;
74
+ onCopy: (entry: StorageEntry) => void;
75
+ onEdit: (entry: StorageEntry) => void;
76
+ onDelete: (key: string) => void;
77
+ badge: {label: string; color: string; bg: string};
78
+ }) {
79
+ const formattedBytes = useMemo(() => {
80
+ return entry.byteSize < 1024
81
+ ? `${entry.byteSize} B`
82
+ : `${(entry.byteSize / 1024).toFixed(1)} KB`;
83
+ }, [entry.byteSize]);
84
+
85
+ // Lazy compute displayed value to avoid layout thrashing on large JSON strings
86
+ const displayValue = useMemo(() => {
87
+ if (!isExpanded) {
88
+ if (entry.value.length > 250) {
89
+ return entry.value.slice(0, 250) + '...';
90
+ }
91
+ return entry.value;
92
+ }
93
+
94
+ if (entry.type === 'json') {
95
+ try {
96
+ const parsed = entry.parsedValue ?? JSON.parse(entry.value);
97
+ return JSON.stringify(parsed, null, 2);
98
+ } catch {
99
+ return entry.value;
100
+ }
101
+ }
102
+ return entry.value;
103
+ }, [isExpanded, entry.value, entry.type, entry.parsedValue]);
104
+
105
+ return (
106
+ <View style={styles.entryCard}>
107
+ {/* Entry Header: Key name, Type Badge, Size, Actions */}
108
+ <View style={styles.entryHeader}>
109
+ <View style={{flex: 1, marginRight: 8}}>
110
+ <View style={{flexDirection: 'row', alignItems: 'center', gap: 6, flexWrap: 'wrap'}}>
111
+ <Text style={styles.entryKey} numberOfLines={1} selectable>
112
+ {entry.key}
113
+ </Text>
114
+ <View
115
+ style={[
116
+ styles.typeBadge,
117
+ {backgroundColor: badge.bg, borderColor: `${badge.color}33`},
118
+ ]}>
119
+ <Text style={[styles.typeBadgeText, {color: badge.color}]}>
120
+ {badge.label}
121
+ </Text>
122
+ </View>
123
+ <Text style={styles.sizeText}>{formattedBytes}</Text>
124
+ </View>
125
+ </View>
126
+
127
+ {/* Actions: Copy, Edit, Delete */}
128
+ <View style={styles.entryActions}>
129
+ <TouchableScale
130
+ hitSlop={6}
131
+ onPress={() => onCopy(entry)}
132
+ style={styles.entryActionBtn}>
133
+ <CopyIcon size={12} color={AppColors.grayText} />
134
+ </TouchableScale>
135
+
136
+ <TouchableScale
137
+ hitSlop={6}
138
+ onPress={() => onEdit(entry)}
139
+ style={styles.entryActionBtn}>
140
+ <PencilIcon size={12} color={AppColors.purple} />
141
+ </TouchableScale>
142
+
143
+ <TouchableScale
144
+ hitSlop={6}
145
+ onPress={() => onDelete(entry.key)}
146
+ style={[styles.entryActionBtn, {backgroundColor: `${AppColors.errorColor}12`}]}>
147
+ <TrashIcon size={12} color={AppColors.errorColor} />
148
+ </TouchableScale>
149
+ </View>
150
+ </View>
151
+
152
+ {/* Entry Value Preview / Viewer */}
153
+ <TouchableScale
154
+ onPress={() => onToggleExpand(entry.key)}
155
+ style={styles.valuePreviewBox}>
156
+ <Text
157
+ style={styles.valuePreviewText}
158
+ numberOfLines={isExpanded ? undefined : 3}
159
+ selectable={isExpanded}>
160
+ {displayValue}
161
+ </Text>
162
+ {entry.value.length > 80 && (
163
+ <Text style={styles.expandHint}>
164
+ {isExpanded ? '▲ Collapse' : '▼ Expand'}
165
+ </Text>
166
+ )}
167
+ </TouchableScale>
168
+ </View>
169
+ );
170
+ });
171
+
46
172
  export const StorageTab = React.memo(() => {
47
173
  const {t} = useTranslation();
48
174
  const [activeDriver, setActiveDriver] = useState<StorageDriver>('asyncStorage');
@@ -235,20 +361,25 @@ export const StorageTab = React.memo(() => {
235
361
  return `${(totalBytes / (1024 * 1024)).toFixed(2)} MB`;
236
362
  }, [totalBytes]);
237
363
 
238
- const getTypeBadge = (type: StorageEntry['type']) => {
239
- switch (type) {
240
- case 'json':
241
- return {label: 'JSON', color: AppColors.purple, bg: `${AppColors.purple}16`};
242
- case 'number':
243
- return {label: 'NUM', color: AppColors.warningIconGold, bg: `${AppColors.warningIconGold}16`};
244
- case 'boolean':
245
- return {label: 'BOOL', color: AppColors.emerald500, bg: `${AppColors.emerald500}16`};
246
- case 'null':
247
- return {label: 'NULL', color: AppColors.grayTextWeak, bg: `${AppColors.grayTextWeak}16`};
248
- default:
249
- return {label: 'STR', color: AppColors.blue500, bg: `${AppColors.blue500}16`};
250
- }
251
- };
364
+ const handleCopyEntry = useCallback((entry: StorageEntry) => {
365
+ copyToClipboard(entry.value, entry.key);
366
+ showToast(`Copied value of "${entry.key}"`);
367
+ }, []);
368
+
369
+ const renderItem = useCallback(
370
+ ({item}: {item: StorageEntry}) => (
371
+ <StorageEntryCard
372
+ entry={item}
373
+ isExpanded={Boolean(expandedKeys[item.key])}
374
+ onToggleExpand={toggleExpand}
375
+ onCopy={handleCopyEntry}
376
+ onEdit={handleOpenEdit}
377
+ onDelete={handleDeleteKey}
378
+ badge={getTypeBadge(item.type)}
379
+ />
380
+ ),
381
+ [expandedKeys, handleCopyEntry],
382
+ );
252
383
 
253
384
  const isConnected =
254
385
  activeDriver === 'asyncStorage'
@@ -423,110 +554,35 @@ export const StorageTab = React.memo(() => {
423
554
  <ActivityIndicator size="small" color={AppColors.purple} />
424
555
  <Text style={styles.loadingText}>Loading storage entries...</Text>
425
556
  </View>
426
- ) : filteredEntries.length === 0 ? (
427
- <ScrollView
428
- contentContainerStyle={styles.emptyContainer}
429
- showsVerticalScrollIndicator={false}>
430
- <View style={styles.emptyIconWrap}>
431
- <DatabaseIcon size={28} color={AppColors.grayTextWeak} />
432
- </View>
433
- <Text style={styles.emptyTitle}>
434
- {search ? 'No matching keys found' : `No ${activeDriver === 'asyncStorage' ? 'AsyncStorage' : 'MMKV'} Keys`}
435
- </Text>
436
- <Text style={styles.emptySubtitle}>
437
- {search
438
- ? 'Try modifying your search query'
439
- : `Storage is auto-detected. Tap "+ Add" above to create your first ${activeDriver === 'asyncStorage' ? 'AsyncStorage' : 'MMKV'} key!`}
440
- </Text>
441
- </ScrollView>
442
557
  ) : (
443
- <ScrollView
558
+ <FlatList
559
+ data={filteredEntries}
560
+ keyExtractor={item => item.key}
561
+ renderItem={renderItem}
562
+ initialNumToRender={10}
563
+ maxToRenderPerBatch={10}
564
+ windowSize={5}
565
+ removeClippedSubviews={Platform.OS === 'android'}
444
566
  style={styles.scrollArea}
445
567
  contentContainerStyle={styles.scrollContent}
446
- showsVerticalScrollIndicator={false}>
447
- {filteredEntries.map(entry => {
448
- const isExpanded = Boolean(expandedKeys[entry.key]);
449
- const badge = getTypeBadge(entry.type);
450
- const formattedBytes =
451
- entry.byteSize < 1024
452
- ? `${entry.byteSize} B`
453
- : `${(entry.byteSize / 1024).toFixed(1)} KB`;
454
-
455
- return (
456
- <View key={entry.key} style={styles.entryCard}>
457
- {/* Entry Header: Key name, Type Badge, Size, Actions */}
458
- <View style={styles.entryHeader}>
459
- <View style={{flex: 1, marginRight: 8}}>
460
- <View style={{flexDirection: 'row', alignItems: 'center', gap: 6, flexWrap: 'wrap'}}>
461
- <Text style={styles.entryKey} numberOfLines={1} selectable>
462
- {entry.key}
463
- </Text>
464
- <View
465
- style={[
466
- styles.typeBadge,
467
- {backgroundColor: badge.bg, borderColor: `${badge.color}33`},
468
- ]}>
469
- <Text style={[styles.typeBadgeText, {color: badge.color}]}>
470
- {badge.label}
471
- </Text>
472
- </View>
473
- <Text style={styles.sizeText}>{formattedBytes}</Text>
474
- </View>
475
- </View>
476
-
477
- {/* Actions: Copy, Edit, Delete */}
478
- <View style={styles.entryActions}>
479
- <TouchableScale
480
- hitSlop={6}
481
- onPress={() => {
482
- copyToClipboard(entry.value, entry.key);
483
- showToast(`Copied value of "${entry.key}"`);
484
- }}
485
- style={styles.entryActionBtn}>
486
- <CopyIcon size={12} color={AppColors.grayText} />
487
- </TouchableScale>
488
-
489
- <TouchableScale
490
- hitSlop={6}
491
- onPress={() => handleOpenEdit(entry)}
492
- style={styles.entryActionBtn}>
493
- <PencilIcon size={12} color={AppColors.purple} />
494
- </TouchableScale>
495
-
496
- <TouchableScale
497
- hitSlop={6}
498
- onPress={() => handleDeleteKey(entry.key)}
499
- style={[styles.entryActionBtn, {backgroundColor: `${AppColors.errorColor}12`}]}>
500
- <TrashIcon size={12} color={AppColors.errorColor} />
501
- </TouchableScale>
502
- </View>
503
- </View>
504
-
505
- {/* Entry Value Preview / Viewer */}
506
- <TouchableScale
507
- onPress={() => toggleExpand(entry.key)}
508
- style={styles.valuePreviewBox}>
509
- <Text
510
- style={styles.valuePreviewText}
511
- numberOfLines={isExpanded ? undefined : 3}
512
- selectable>
513
- {entry.type === 'json' && entry.parsedValue
514
- ? isExpanded
515
- ? JSON.stringify(entry.parsedValue, null, 2)
516
- : entry.value
517
- : entry.value}
518
- </Text>
519
- {entry.value.length > 80 && (
520
- <Text style={styles.expandHint}>
521
- {isExpanded ? '▲ Collapse' : '▼ Expand'}
522
- </Text>
523
- )}
524
- </TouchableScale>
568
+ showsVerticalScrollIndicator={false}
569
+ ListEmptyComponent={
570
+ <View style={styles.emptyContainer}>
571
+ <View style={styles.emptyIconWrap}>
572
+ <DatabaseIcon size={28} color={AppColors.grayTextWeak} />
525
573
  </View>
526
- );
527
- })}
528
- <View style={{height: 60}} />
529
- </ScrollView>
574
+ <Text style={styles.emptyTitle}>
575
+ {search ? 'No matching keys found' : `No ${activeDriver === 'asyncStorage' ? 'AsyncStorage' : 'MMKV'} Keys`}
576
+ </Text>
577
+ <Text style={styles.emptySubtitle}>
578
+ {search
579
+ ? 'Try modifying your search query'
580
+ : `Storage is auto-detected. Tap "+ Add" above to create your first ${activeDriver === 'asyncStorage' ? 'AsyncStorage' : 'MMKV'} key!`}
581
+ </Text>
582
+ </View>
583
+ }
584
+ ListFooterComponent={<View style={{height: 60}} />}
585
+ />
530
586
  )}
531
587
 
532
588
  {/* ── Create / Edit Key Modal ── */}