react-native-debug-toolkit 2.3.0 → 3.1.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.
Files changed (93) hide show
  1. package/README.md +115 -97
  2. package/README.zh-CN.md +113 -95
  3. package/bin/debug-toolkit.js +114 -0
  4. package/lib/commonjs/core/initialize.js +5 -0
  5. package/lib/commonjs/core/initialize.js.map +1 -1
  6. package/lib/commonjs/features/network/index.js +28 -2
  7. package/lib/commonjs/features/network/index.js.map +1 -1
  8. package/lib/commonjs/features/network/networkInterceptor.js +14 -6
  9. package/lib/commonjs/features/network/networkInterceptor.js.map +1 -1
  10. package/lib/commonjs/index.js +56 -0
  11. package/lib/commonjs/index.js.map +1 -1
  12. package/lib/commonjs/ui/panel/DebugPanel.js +25 -0
  13. package/lib/commonjs/ui/panel/DebugPanel.js.map +1 -1
  14. package/lib/commonjs/ui/panel/FloatPanelView.js +15 -62
  15. package/lib/commonjs/ui/panel/FloatPanelView.js.map +1 -1
  16. package/lib/commonjs/ui/panel/StreamingSettingsModal.js +495 -0
  17. package/lib/commonjs/ui/panel/StreamingSettingsModal.js.map +1 -0
  18. package/lib/commonjs/ui/panel/useTabAnimation.js +71 -0
  19. package/lib/commonjs/ui/panel/useTabAnimation.js.map +1 -0
  20. package/lib/commonjs/utils/DaemonClient.js +721 -0
  21. package/lib/commonjs/utils/DaemonClient.js.map +1 -0
  22. package/lib/commonjs/utils/createPersistedObservableStore.js +23 -3
  23. package/lib/commonjs/utils/createPersistedObservableStore.js.map +1 -1
  24. package/lib/commonjs/utils/deviceReport.js +132 -0
  25. package/lib/commonjs/utils/deviceReport.js.map +1 -0
  26. package/lib/module/core/initialize.js +6 -0
  27. package/lib/module/core/initialize.js.map +1 -1
  28. package/lib/module/features/network/index.js +25 -1
  29. package/lib/module/features/network/index.js.map +1 -1
  30. package/lib/module/features/network/networkInterceptor.js +14 -6
  31. package/lib/module/features/network/networkInterceptor.js.map +1 -1
  32. package/lib/module/index.js +3 -0
  33. package/lib/module/index.js.map +1 -1
  34. package/lib/module/ui/panel/DebugPanel.js +26 -1
  35. package/lib/module/ui/panel/DebugPanel.js.map +1 -1
  36. package/lib/module/ui/panel/FloatPanelView.js +16 -63
  37. package/lib/module/ui/panel/FloatPanelView.js.map +1 -1
  38. package/lib/module/ui/panel/StreamingSettingsModal.js +490 -0
  39. package/lib/module/ui/panel/StreamingSettingsModal.js.map +1 -0
  40. package/lib/module/ui/panel/useTabAnimation.js +67 -0
  41. package/lib/module/ui/panel/useTabAnimation.js.map +1 -0
  42. package/lib/module/utils/DaemonClient.js +703 -0
  43. package/lib/module/utils/DaemonClient.js.map +1 -0
  44. package/lib/module/utils/createPersistedObservableStore.js +23 -3
  45. package/lib/module/utils/createPersistedObservableStore.js.map +1 -1
  46. package/lib/module/utils/deviceReport.js +128 -0
  47. package/lib/module/utils/deviceReport.js.map +1 -0
  48. package/lib/typescript/src/core/initialize.d.ts.map +1 -1
  49. package/lib/typescript/src/features/network/index.d.ts +2 -0
  50. package/lib/typescript/src/features/network/index.d.ts.map +1 -1
  51. package/lib/typescript/src/features/network/networkInterceptor.d.ts +1 -1
  52. package/lib/typescript/src/features/network/networkInterceptor.d.ts.map +1 -1
  53. package/lib/typescript/src/index.d.ts +5 -0
  54. package/lib/typescript/src/index.d.ts.map +1 -1
  55. package/lib/typescript/src/ui/panel/DebugPanel.d.ts.map +1 -1
  56. package/lib/typescript/src/ui/panel/FloatPanelView.d.ts.map +1 -1
  57. package/lib/typescript/src/ui/panel/StreamingSettingsModal.d.ts +8 -0
  58. package/lib/typescript/src/ui/panel/StreamingSettingsModal.d.ts.map +1 -0
  59. package/lib/typescript/src/ui/panel/useTabAnimation.d.ts +14 -0
  60. package/lib/typescript/src/ui/panel/useTabAnimation.d.ts.map +1 -0
  61. package/lib/typescript/src/utils/DaemonClient.d.ts +141 -0
  62. package/lib/typescript/src/utils/DaemonClient.d.ts.map +1 -0
  63. package/lib/typescript/src/utils/createPersistedObservableStore.d.ts +2 -1
  64. package/lib/typescript/src/utils/createPersistedObservableStore.d.ts.map +1 -1
  65. package/lib/typescript/src/utils/deviceReport.d.ts +18 -0
  66. package/lib/typescript/src/utils/deviceReport.d.ts.map +1 -0
  67. package/node/daemon/src/cli.js +82 -0
  68. package/node/daemon/src/console/console.html +1662 -0
  69. package/node/daemon/src/console/index.js +47 -0
  70. package/node/daemon/src/constants.js +38 -0
  71. package/node/daemon/src/index.js +11 -0
  72. package/node/daemon/src/server.js +447 -0
  73. package/node/daemon/src/store.js +187 -0
  74. package/node/mcp/src/cli.js +31 -0
  75. package/node/mcp/src/constants.js +13 -0
  76. package/node/mcp/src/daemonClient.js +132 -0
  77. package/node/mcp/src/httpClient.js +49 -0
  78. package/node/mcp/src/index.js +15 -0
  79. package/node/mcp/src/logs.js +96 -0
  80. package/node/mcp/src/server.js +144 -0
  81. package/node/mcp/src/tools.js +84 -0
  82. package/package.json +8 -3
  83. package/src/core/initialize.ts +8 -0
  84. package/src/features/network/index.ts +30 -3
  85. package/src/features/network/networkInterceptor.ts +19 -6
  86. package/src/index.ts +22 -0
  87. package/src/ui/panel/DebugPanel.tsx +23 -1
  88. package/src/ui/panel/FloatPanelView.tsx +10 -68
  89. package/src/ui/panel/StreamingSettingsModal.tsx +528 -0
  90. package/src/ui/panel/useTabAnimation.ts +77 -0
  91. package/src/utils/DaemonClient.ts +887 -0
  92. package/src/utils/createPersistedObservableStore.ts +16 -3
  93. package/src/utils/deviceReport.ts +203 -0
@@ -0,0 +1,528 @@
1
+ import React, { useCallback, useEffect, useRef, useState } from 'react';
2
+ import {
3
+ KeyboardAvoidingView,
4
+ Modal,
5
+ Platform,
6
+ ScrollView,
7
+ View,
8
+ Text,
9
+ TextInput,
10
+ TouchableOpacity,
11
+ StyleSheet,
12
+ Pressable,
13
+ } from 'react-native';
14
+ import { Colors } from '../theme/colors';
15
+ import {
16
+ daemonClient,
17
+ type DaemonConnectionMode,
18
+ type DaemonSettings,
19
+ buildDeviceDaemonEndpoint,
20
+ normalizeDaemonSettings,
21
+ getDefaultDaemonEndpoint,
22
+ } from '../../utils/DaemonClient';
23
+
24
+ interface StreamingSettingsModalProps {
25
+ visible: boolean;
26
+ onClose: () => void;
27
+ }
28
+
29
+ type SyncUiState = 'idle' | 'connecting' | 'connected' | 'retrying' | 'failed' | 'running';
30
+
31
+ const CONNECTION_TIMEOUT_MS = 2000;
32
+
33
+ function formatConnectionFailure(): string {
34
+ return 'Cannot reach desktop. Try /health in phone browser.';
35
+ }
36
+
37
+ export function StreamingSettingsModal({ visible, onClose }: StreamingSettingsModalProps) {
38
+ const inputRef = useRef<TextInput>(null);
39
+ const [mode, setMode] = useState<DaemonConnectionMode>('simulator');
40
+ const [deviceHost, setDeviceHost] = useState('');
41
+ const [streaming, setStreaming] = useState(daemonClient.isConnected());
42
+ const [syncState, setSyncState] = useState<SyncUiState>(daemonClient.isConnected() ? 'running' : 'idle');
43
+ const [message, setMessage] = useState<string | null>(null);
44
+ const [sending, setSending] = useState(false);
45
+
46
+ const handleDeviceHostChange = useCallback((value: string) => {
47
+ setDeviceHost(value);
48
+ if (syncState === 'failed') {
49
+ setSyncState('idle');
50
+ }
51
+ setMessage(null);
52
+ }, [syncState]);
53
+
54
+ const detectDeviceHost = useCallback(async () => {
55
+ setMessage('Enter your Mac IP, or open /health on the phone browser to verify reachability.');
56
+ }, []);
57
+
58
+ useEffect(() => {
59
+ if (visible) {
60
+ const settings = daemonClient.getSettings();
61
+ setMode(settings.mode);
62
+ setDeviceHost(settings.deviceHost);
63
+ }
64
+ }, [visible]);
65
+
66
+ useEffect(() => {
67
+ const active = daemonClient.isConnected();
68
+ setStreaming(active);
69
+ setSyncState(active ? 'running' : 'idle');
70
+ }, [visible]);
71
+
72
+ const getSettings = useCallback((): DaemonSettings => ({
73
+ mode,
74
+ endpoint: '',
75
+ deviceHost,
76
+ token: '',
77
+ }), [deviceHost, mode]);
78
+
79
+ const validateSettings = useCallback((): boolean => {
80
+ if (mode === 'device' && !deviceHost.trim()) {
81
+ setMessage('Enter your Mac IP first.');
82
+ return false;
83
+ }
84
+ return true;
85
+ }, [deviceHost, mode]);
86
+
87
+ const handleModeChange = useCallback((nextMode: DaemonConnectionMode) => {
88
+ setMode(nextMode);
89
+ }, []);
90
+
91
+ const toggleLiveSync = useCallback(async () => {
92
+ if (streaming) {
93
+ daemonClient.disconnect();
94
+ daemonClient.setStreamingEnabled(false);
95
+ setStreaming(false);
96
+ setSyncState('idle');
97
+ setMessage(null);
98
+ return;
99
+ }
100
+
101
+ if (!validateSettings()) {
102
+ return;
103
+ }
104
+
105
+ const settings = getSettings();
106
+ const daemonOptions = normalizeDaemonSettings(settings);
107
+ setMessage('Checking desktop connection...');
108
+ setSyncState('connecting');
109
+ daemonClient.configure(settings);
110
+
111
+ const connection = await daemonClient.checkConnection({
112
+ ...daemonOptions,
113
+ timeoutMs: CONNECTION_TIMEOUT_MS,
114
+ });
115
+ if (!connection.ok) {
116
+ setStreaming(false);
117
+ setSyncState('failed');
118
+ setMessage(formatConnectionFailure());
119
+ return;
120
+ }
121
+
122
+ daemonClient.setStreamingEnabled(true);
123
+ daemonClient.connect({
124
+ ...daemonOptions,
125
+ timeoutMs: 3000,
126
+ onStatus: (status) => {
127
+ if (status.state === 'connected') {
128
+ setSyncState('connected');
129
+ setMessage(null);
130
+ } else if (status.state === 'retrying') {
131
+ setSyncState('retrying');
132
+ setMessage('Desktop not reachable. Retrying...');
133
+ } else if (status.state === 'failed') {
134
+ setStreaming(false);
135
+ setSyncState('failed');
136
+ setMessage(
137
+ status.reason === 'auth'
138
+ ? 'Desktop token rejected.'
139
+ : 'Desktop not reachable after multiple retries.',
140
+ );
141
+ } else {
142
+ setSyncState('connecting');
143
+ }
144
+ },
145
+ });
146
+ setStreaming(true);
147
+ }, [getSettings, streaming, validateSettings]);
148
+
149
+ const sendOnce = useCallback(async () => {
150
+ if (!validateSettings()) {
151
+ return;
152
+ }
153
+
154
+ const settings = getSettings();
155
+ const daemonOptions = normalizeDaemonSettings(settings);
156
+ setSending(true);
157
+ setMessage('Checking desktop connection...');
158
+ daemonClient.configure(settings);
159
+
160
+ try {
161
+ const connection = await daemonClient.checkConnection({
162
+ ...daemonOptions,
163
+ timeoutMs: CONNECTION_TIMEOUT_MS,
164
+ });
165
+ if (!connection.ok) {
166
+ setMessage(formatConnectionFailure());
167
+ return;
168
+ }
169
+
170
+ setMessage('Sending logs...');
171
+ const result = await daemonClient.reportOnce({
172
+ ...daemonOptions,
173
+ timeoutMs: 2000,
174
+ });
175
+
176
+ if (result.ok) {
177
+ const totalLogs = Object.values(result.logCount ?? {}).reduce((total, count) => total + count, 0);
178
+ setMessage(`Sent ${totalLogs} logs.`);
179
+ } else {
180
+ setMessage(result.error ? `Send failed: ${result.error}` : 'Send failed.');
181
+ }
182
+ } finally {
183
+ setSending(false);
184
+ }
185
+ }, [getSettings, validateSettings]);
186
+
187
+ const target = mode === 'device'
188
+ ? buildDeviceDaemonEndpoint(deviceHost) || 'Enter Mac IP'
189
+ : getDefaultDaemonEndpoint();
190
+ const canConnect = mode === 'simulator' || Boolean(deviceHost.trim());
191
+ const connecting = !streaming && syncState === 'connecting';
192
+ const busy = sending || connecting;
193
+ const statusTitle = sending
194
+ ? 'Checking'
195
+ : connecting
196
+ ? 'Checking'
197
+ : streaming && syncState === 'connected'
198
+ ? 'Live sync connected'
199
+ : streaming && syncState === 'retrying'
200
+ ? 'Retrying desktop sync'
201
+ : syncState === 'failed'
202
+ ? 'Failed'
203
+ : streaming
204
+ ? 'Live sync running'
205
+ : mode === 'device' && !deviceHost.trim()
206
+ ? 'Enter Mac IP'
207
+ : 'Ready';
208
+
209
+ return (
210
+ <Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
211
+ <KeyboardAvoidingView
212
+ style={styles.keyboardAvoiding}
213
+ behavior={Platform.OS === 'ios' ? 'padding' : undefined}
214
+ >
215
+ <Pressable style={styles.backdrop} onPress={onClose}>
216
+ <Pressable style={styles.sheet} onPress={(e) => e.stopPropagation()}>
217
+ <View style={styles.handle} />
218
+
219
+ <View style={styles.header}>
220
+ <Text style={styles.title}>Desktop Logs</Text>
221
+ <TouchableOpacity onPress={onClose} style={styles.closeButton} activeOpacity={0.7}>
222
+ <Text style={styles.closeButtonText}>Close</Text>
223
+ </TouchableOpacity>
224
+ </View>
225
+
226
+ <ScrollView
227
+ keyboardShouldPersistTaps="handled"
228
+ showsVerticalScrollIndicator={false}
229
+ contentContainerStyle={styles.scrollContent}
230
+ >
231
+ <View style={styles.statusCard}>
232
+ <View style={[styles.statusDot, streaming ? styles.dotActive : styles.dotInactive]} />
233
+ <View style={styles.statusCopy}>
234
+ <Text style={styles.statusTitle}>{statusTitle}</Text>
235
+ <Text style={styles.statusTarget} numberOfLines={1}>{target}</Text>
236
+ </View>
237
+ </View>
238
+
239
+ <View style={styles.section}>
240
+ <Text style={styles.label}>Connection</Text>
241
+ <View style={styles.segment}>
242
+ <TouchableOpacity
243
+ style={[styles.segmentButton, mode === 'simulator' && styles.segmentButtonActive]}
244
+ onPress={() => handleModeChange('simulator')}
245
+ disabled={streaming}
246
+ activeOpacity={0.7}
247
+ >
248
+ <Text style={[styles.segmentText, mode === 'simulator' && styles.segmentTextActive]}>
249
+ Simulator
250
+ </Text>
251
+ </TouchableOpacity>
252
+ <TouchableOpacity
253
+ style={[styles.segmentButton, mode === 'device' && styles.segmentButtonActive]}
254
+ onPress={() => handleModeChange('device')}
255
+ disabled={streaming}
256
+ activeOpacity={0.7}
257
+ >
258
+ <Text style={[styles.segmentText, mode === 'device' && styles.segmentTextActive]}>
259
+ Real device
260
+ </Text>
261
+ </TouchableOpacity>
262
+ </View>
263
+ </View>
264
+
265
+ {mode === 'device' ? (
266
+ <View style={styles.section}>
267
+ <View style={styles.inputRow}>
268
+ <Text style={styles.inputLabel}>Mac IP</Text>
269
+ <TextInput
270
+ ref={inputRef}
271
+ style={styles.input}
272
+ value={deviceHost}
273
+ onChangeText={handleDeviceHostChange}
274
+ placeholder="192.168.1.10"
275
+ placeholderTextColor={Colors.textLight}
276
+ autoCapitalize="none"
277
+ autoCorrect={false}
278
+ keyboardType="numbers-and-punctuation"
279
+ returnKeyType="done"
280
+ onSubmitEditing={() => inputRef.current?.blur()}
281
+ editable={!streaming}
282
+ />
283
+ <TouchableOpacity
284
+ style={[styles.detectButton, (streaming || busy) && styles.buttonDisabled]}
285
+ onPress={detectDeviceHost}
286
+ disabled={streaming || busy}
287
+ activeOpacity={0.7}
288
+ >
289
+ <Text style={styles.detectButtonText}>?</Text>
290
+ </TouchableOpacity>
291
+ </View>
292
+ </View>
293
+ ) : null}
294
+
295
+ {message ? <Text style={styles.message}>{message}</Text> : null}
296
+
297
+ <View style={styles.actions}>
298
+ <TouchableOpacity
299
+ style={[styles.primaryButton, (!canConnect || busy) && styles.buttonDisabled]}
300
+ onPress={toggleLiveSync}
301
+ disabled={!canConnect || busy}
302
+ activeOpacity={0.75}
303
+ >
304
+ <Text style={styles.primaryButtonText}>
305
+ {streaming ? 'Stop Live Sync' : busy ? 'Checking...' : 'Start Live Sync'}
306
+ </Text>
307
+ </TouchableOpacity>
308
+ <TouchableOpacity
309
+ style={[styles.secondaryButton, (!canConnect || busy) && styles.buttonDisabled]}
310
+ onPress={sendOnce}
311
+ disabled={!canConnect || busy}
312
+ activeOpacity={0.75}
313
+ >
314
+ <Text style={styles.secondaryButtonText}>{sending ? 'Sending...' : 'Send Once'}</Text>
315
+ </TouchableOpacity>
316
+ </View>
317
+ </ScrollView>
318
+ </Pressable>
319
+ </Pressable>
320
+ </KeyboardAvoidingView>
321
+ </Modal>
322
+ );
323
+ }
324
+
325
+ const styles = StyleSheet.create({
326
+ keyboardAvoiding: {
327
+ flex: 1,
328
+ },
329
+ backdrop: {
330
+ flex: 1,
331
+ backgroundColor: 'rgba(0,0,0,0.4)',
332
+ justifyContent: 'flex-end',
333
+ },
334
+ sheet: {
335
+ backgroundColor: Colors.surface,
336
+ borderTopLeftRadius: 20,
337
+ borderTopRightRadius: 20,
338
+ paddingHorizontal: 20,
339
+ paddingTop: 12,
340
+ paddingBottom: 20,
341
+ maxHeight: '82%',
342
+ },
343
+ handle: {
344
+ width: 40,
345
+ height: 4,
346
+ borderRadius: 2,
347
+ backgroundColor: Colors.textLight,
348
+ alignSelf: 'center',
349
+ marginBottom: 16,
350
+ },
351
+ header: {
352
+ flexDirection: 'row',
353
+ alignItems: 'center',
354
+ justifyContent: 'space-between',
355
+ marginBottom: 14,
356
+ },
357
+ title: {
358
+ fontSize: 18,
359
+ fontWeight: '600',
360
+ color: Colors.text,
361
+ },
362
+ closeButton: {
363
+ paddingHorizontal: 4,
364
+ paddingVertical: 4,
365
+ },
366
+ closeButtonText: {
367
+ fontSize: 14,
368
+ fontWeight: '500',
369
+ color: Colors.primary,
370
+ },
371
+ scrollContent: {
372
+ paddingBottom: 4,
373
+ },
374
+ statusCard: {
375
+ flexDirection: 'row',
376
+ alignItems: 'center',
377
+ gap: 10,
378
+ padding: 12,
379
+ borderRadius: 8,
380
+ backgroundColor: Colors.background,
381
+ borderWidth: 1,
382
+ borderColor: Colors.border,
383
+ marginBottom: 16,
384
+ },
385
+ statusCopy: {
386
+ flex: 1,
387
+ },
388
+ statusTitle: {
389
+ fontSize: 14,
390
+ fontWeight: '600',
391
+ color: Colors.text,
392
+ },
393
+ statusTarget: {
394
+ marginTop: 2,
395
+ fontSize: 12,
396
+ color: Colors.textLight,
397
+ fontFamily: 'Courier',
398
+ },
399
+ section: {
400
+ marginBottom: 14,
401
+ },
402
+ label: {
403
+ fontSize: 13,
404
+ fontWeight: '500',
405
+ color: Colors.textSecondary,
406
+ marginBottom: 6,
407
+ },
408
+ segment: {
409
+ flexDirection: 'row',
410
+ padding: 3,
411
+ borderRadius: 10,
412
+ backgroundColor: Colors.background,
413
+ borderWidth: 1,
414
+ borderColor: Colors.border,
415
+ },
416
+ segmentButton: {
417
+ flex: 1,
418
+ alignItems: 'center',
419
+ paddingVertical: 9,
420
+ borderRadius: 7,
421
+ },
422
+ segmentButtonActive: {
423
+ backgroundColor: Colors.surface,
424
+ borderWidth: 1,
425
+ borderColor: Colors.primary,
426
+ },
427
+ segmentText: {
428
+ fontSize: 13,
429
+ fontWeight: '500',
430
+ color: Colors.textSecondary,
431
+ },
432
+ segmentTextActive: {
433
+ color: Colors.primary,
434
+ fontWeight: '600',
435
+ },
436
+ inputRow: {
437
+ flexDirection: 'row',
438
+ alignItems: 'center',
439
+ gap: 8,
440
+ },
441
+ inputLabel: {
442
+ width: 50,
443
+ fontSize: 13,
444
+ fontWeight: '600',
445
+ color: Colors.textSecondary,
446
+ },
447
+ input: {
448
+ flex: 1,
449
+ backgroundColor: Colors.background,
450
+ borderWidth: 1,
451
+ borderColor: Colors.border,
452
+ borderRadius: 8,
453
+ paddingHorizontal: 12,
454
+ paddingVertical: 10,
455
+ fontSize: 14,
456
+ color: Colors.text,
457
+ fontFamily: 'Courier',
458
+ },
459
+ detectButton: {
460
+ minWidth: 68,
461
+ alignItems: 'center',
462
+ justifyContent: 'center',
463
+ paddingHorizontal: 10,
464
+ paddingVertical: 10,
465
+ borderRadius: 8,
466
+ backgroundColor: Colors.surface,
467
+ borderWidth: 1,
468
+ borderColor: Colors.primary,
469
+ },
470
+ detectButtonText: {
471
+ color: Colors.primary,
472
+ fontSize: 13,
473
+ fontWeight: '600',
474
+ },
475
+ statusDot: {
476
+ width: 8,
477
+ height: 8,
478
+ borderRadius: 4,
479
+ },
480
+ dotActive: {
481
+ backgroundColor: Colors.success,
482
+ },
483
+ dotInactive: {
484
+ backgroundColor: Colors.textLight,
485
+ },
486
+ message: {
487
+ fontSize: 12,
488
+ lineHeight: 17,
489
+ color: Colors.textSecondary,
490
+ marginBottom: 12,
491
+ },
492
+ actions: {
493
+ flexDirection: 'row',
494
+ gap: 10,
495
+ marginTop: 4,
496
+ },
497
+ primaryButton: {
498
+ flex: 1,
499
+ alignItems: 'center',
500
+ justifyContent: 'center',
501
+ paddingVertical: 11,
502
+ borderRadius: 10,
503
+ backgroundColor: Colors.primary,
504
+ },
505
+ primaryButtonText: {
506
+ color: '#fff',
507
+ fontSize: 14,
508
+ fontWeight: '600',
509
+ },
510
+ secondaryButton: {
511
+ flex: 1,
512
+ alignItems: 'center',
513
+ justifyContent: 'center',
514
+ paddingVertical: 11,
515
+ borderRadius: 10,
516
+ backgroundColor: Colors.background,
517
+ borderWidth: 1,
518
+ borderColor: Colors.border,
519
+ },
520
+ secondaryButtonText: {
521
+ color: Colors.primary,
522
+ fontSize: 14,
523
+ fontWeight: '600',
524
+ },
525
+ buttonDisabled: {
526
+ opacity: 0.5,
527
+ },
528
+ });
@@ -0,0 +1,77 @@
1
+ import { useCallback, useRef } from 'react';
2
+ import { Animated, PanResponder, Easing } from 'react-native';
3
+
4
+ interface UseTabAnimationOptions {
5
+ activeTab: number;
6
+ tabCount: number;
7
+ onTabChange: (index: number) => void;
8
+ }
9
+
10
+ export function useTabAnimation({ activeTab, tabCount, onTabChange }: UseTabAnimationOptions) {
11
+ const contentOpacity = useRef(new Animated.Value(1)).current;
12
+ const contentTranslateX = useRef(new Animated.Value(0)).current;
13
+ const isSwitchingTab = useRef(false);
14
+
15
+ const activeTabRef = useRef(activeTab);
16
+ activeTabRef.current = activeTab;
17
+ const tabCountRef = useRef(tabCount);
18
+ tabCountRef.current = tabCount;
19
+ const switchTabRef = useRef<(index: number) => void>(() => {});
20
+
21
+ const swipeResponder = useRef(
22
+ PanResponder.create({
23
+ onStartShouldSetPanResponder: () => false,
24
+ onMoveShouldSetPanResponder: (_, gs) => {
25
+ if (isSwitchingTab.current) return false;
26
+ return Math.abs(gs.dx) > 25 && Math.abs(gs.dx) > Math.abs(gs.dy) * 2.5;
27
+ },
28
+ onPanResponderRelease: (_, gs) => {
29
+ const tab = activeTabRef.current;
30
+ if (gs.dx < -40 && tab < tabCountRef.current - 1) switchTabRef.current(tab + 1);
31
+ else if (gs.dx > 40 && tab > 0) switchTabRef.current(tab - 1);
32
+ },
33
+ onPanResponderTerminationRequest: () => true,
34
+ }),
35
+ ).current;
36
+
37
+ const switchTab = useCallback(
38
+ (index: number) => {
39
+ if (isSwitchingTab.current || index === activeTabRef.current) return;
40
+ isSwitchingTab.current = true;
41
+ const direction = index > activeTabRef.current ? 1 : -1;
42
+
43
+ Animated.parallel([
44
+ Animated.timing(contentOpacity, { toValue: 0, duration: 80, useNativeDriver: true }),
45
+ Animated.timing(contentTranslateX, {
46
+ toValue: -direction * 40,
47
+ duration: 80,
48
+ useNativeDriver: true,
49
+ }),
50
+ ]).start(() => {
51
+ onTabChange(index);
52
+ contentTranslateX.setValue(direction * 40);
53
+ Animated.parallel([
54
+ Animated.timing(contentOpacity, { toValue: 1, duration: 150, useNativeDriver: true }),
55
+ Animated.timing(contentTranslateX, {
56
+ toValue: 0,
57
+ duration: 200,
58
+ easing: Easing.out(Easing.cubic),
59
+ useNativeDriver: true,
60
+ }),
61
+ ]).start(() => {
62
+ isSwitchingTab.current = false;
63
+ });
64
+ });
65
+ },
66
+ [contentOpacity, contentTranslateX, onTabChange],
67
+ );
68
+
69
+ switchTabRef.current = switchTab;
70
+
71
+ return {
72
+ contentOpacity,
73
+ contentTranslateX,
74
+ panHandlers: swipeResponder.panHandlers,
75
+ switchTab,
76
+ };
77
+ }