@umituz/react-native-design-system 2.6.116 → 2.6.118
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.
- package/package.json +6 -3
- package/src/exports/offline.ts +7 -0
- package/src/index.ts +5 -0
- package/src/offline/index.ts +35 -0
- package/src/offline/infrastructure/events/NetworkEvents.ts +52 -0
- package/src/offline/infrastructure/storage/OfflineStore.ts +79 -0
- package/src/offline/infrastructure/utils/healthCheck.ts +116 -0
- package/src/offline/presentation/components/OfflineBanner.tsx +173 -0
- package/src/offline/presentation/hooks/useOffline.ts +89 -0
- package/src/offline/presentation/hooks/useOfflineState.ts +15 -0
- package/src/offline/presentation/hooks/useOfflineWithMutations.ts +47 -0
- package/src/offline/presentation/providers/NetworkProvider.tsx +33 -0
- package/src/offline/types/index.ts +67 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umituz/react-native-design-system",
|
|
3
|
-
"version": "2.6.
|
|
4
|
-
"description": "Universal design system for React Native apps - Consolidated package with atoms, molecules, organisms, theme, typography, responsive, safe area, exception, infinite scroll, UUID, image and
|
|
3
|
+
"version": "2.6.118",
|
|
4
|
+
"description": "Universal design system for React Native apps - Consolidated package with atoms, molecules, organisms, theme, typography, responsive, safe area, exception, infinite scroll, UUID, image, timezone and offline utilities",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
7
7
|
"exports": {
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"./device": "./src/device/index.ts",
|
|
22
22
|
"./image": "./src/image/index.ts",
|
|
23
23
|
"./timezone": "./src/timezone/index.ts",
|
|
24
|
+
"./offline": "./src/offline/index.ts",
|
|
24
25
|
"./package.json": "./package.json"
|
|
25
26
|
},
|
|
26
27
|
"scripts": {
|
|
@@ -44,7 +45,8 @@
|
|
|
44
45
|
"responsive",
|
|
45
46
|
"safe-area",
|
|
46
47
|
"image",
|
|
47
|
-
"timezone"
|
|
48
|
+
"timezone",
|
|
49
|
+
"offline"
|
|
48
50
|
],
|
|
49
51
|
"author": "Ümit UZ <umit@umituz.com>",
|
|
50
52
|
"license": "MIT",
|
|
@@ -69,6 +71,7 @@
|
|
|
69
71
|
"expo-device": ">=5.0.0",
|
|
70
72
|
"expo-font": ">=12.0.0",
|
|
71
73
|
"expo-image": ">=3.0.0",
|
|
74
|
+
"expo-network": ">=8.0.0",
|
|
72
75
|
"expo-sharing": ">=12.0.0",
|
|
73
76
|
"react": ">=19.0.0",
|
|
74
77
|
"react-native": ">=0.81.0",
|
package/src/index.ts
CHANGED
|
@@ -77,6 +77,11 @@ export * from './exports/uuid';
|
|
|
77
77
|
// =============================================================================
|
|
78
78
|
export * from './exports/timezone';
|
|
79
79
|
|
|
80
|
+
// =============================================================================
|
|
81
|
+
// OFFLINE EXPORTS
|
|
82
|
+
// =============================================================================
|
|
83
|
+
export * from './exports/offline';
|
|
84
|
+
|
|
80
85
|
// =============================================================================
|
|
81
86
|
// VARIANT UTILITIES
|
|
82
87
|
// =============================================================================
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @umituz/react-native-offline
|
|
3
|
+
* Network connectivity state management for React Native apps
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// Types
|
|
7
|
+
export type {
|
|
8
|
+
NetworkState,
|
|
9
|
+
OfflineState,
|
|
10
|
+
OfflineStore,
|
|
11
|
+
OfflineConfig,
|
|
12
|
+
ConnectionQuality,
|
|
13
|
+
} from './types';
|
|
14
|
+
|
|
15
|
+
// Store
|
|
16
|
+
export { useOfflineStore } from './infrastructure/storage/OfflineStore';
|
|
17
|
+
|
|
18
|
+
// Hooks
|
|
19
|
+
export { useOffline, configureOffline } from './presentation/hooks/useOffline';
|
|
20
|
+
export { useOfflineState } from './presentation/hooks/useOfflineState';
|
|
21
|
+
export { useOfflineWithMutations } from './presentation/hooks/useOfflineWithMutations';
|
|
22
|
+
|
|
23
|
+
// Components
|
|
24
|
+
export { OfflineBanner } from './presentation/components/OfflineBanner';
|
|
25
|
+
export type { OfflineBannerProps } from './presentation/components/OfflineBanner';
|
|
26
|
+
|
|
27
|
+
// Providers
|
|
28
|
+
export { NetworkProvider } from './presentation/providers/NetworkProvider';
|
|
29
|
+
|
|
30
|
+
// Events
|
|
31
|
+
export { networkEvents } from './infrastructure/events/NetworkEvents';
|
|
32
|
+
export type { NetworkEventListener, NetworkEvents } from './infrastructure/events/NetworkEvents';
|
|
33
|
+
|
|
34
|
+
// Utils
|
|
35
|
+
export { HealthCheck } from './infrastructure/utils/healthCheck';
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Network Events
|
|
3
|
+
* Event emitter for network state changes
|
|
4
|
+
* Allows subscribing to network events throughout the app
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { NetworkState } from '../../types';
|
|
8
|
+
|
|
9
|
+
export type NetworkEventListener = (state: NetworkState) => void;
|
|
10
|
+
|
|
11
|
+
export interface NetworkEvents {
|
|
12
|
+
on(event: 'online', listener: NetworkEventListener): void;
|
|
13
|
+
on(event: 'offline', listener: NetworkEventListener): void;
|
|
14
|
+
on(event: 'change', listener: NetworkEventListener): void;
|
|
15
|
+
off(event: 'online' | 'offline' | 'change', listener: NetworkEventListener): void;
|
|
16
|
+
emit(event: 'online' | 'offline' | 'change', state: NetworkState): void;
|
|
17
|
+
removeAllListeners(): void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
class NetworkEventEmitter implements NetworkEvents {
|
|
21
|
+
private listeners: Map<string, Set<NetworkEventListener>> = new Map();
|
|
22
|
+
|
|
23
|
+
on(event: 'online' | 'offline' | 'change', listener: NetworkEventListener): void {
|
|
24
|
+
if (!this.listeners.has(event)) {
|
|
25
|
+
this.listeners.set(event, new Set());
|
|
26
|
+
}
|
|
27
|
+
this.listeners.get(event)?.add(listener);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
off(event: 'online' | 'offline' | 'change', listener: NetworkEventListener): void {
|
|
31
|
+
this.listeners.get(event)?.delete(listener);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
emit(event: 'online' | 'offline' | 'change', state: NetworkState): void {
|
|
35
|
+
this.listeners.get(event)?.forEach((listener) => {
|
|
36
|
+
try {
|
|
37
|
+
listener(state);
|
|
38
|
+
} catch (error) {
|
|
39
|
+
if (__DEV__) {
|
|
40
|
+
// eslint-disable-next-line no-console
|
|
41
|
+
console.error(`[NetworkEventEmitter] Error in ${event} listener:`, error);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
removeAllListeners(): void {
|
|
48
|
+
this.listeners.clear();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const networkEvents = new NetworkEventEmitter();
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Offline Store
|
|
3
|
+
* Manages network connectivity state across the application
|
|
4
|
+
* Uses expo-network for universal network detection
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { createStore } from '@umituz/react-native-storage';
|
|
8
|
+
import type { NetworkState, OfflineState, OfflineActions } from '../../types';
|
|
9
|
+
|
|
10
|
+
const initialState: OfflineState = {
|
|
11
|
+
isOnline: true,
|
|
12
|
+
isOffline: false,
|
|
13
|
+
connectionType: null,
|
|
14
|
+
isInternetReachable: null,
|
|
15
|
+
lastOnlineAt: new Date(),
|
|
16
|
+
lastOfflineAt: null,
|
|
17
|
+
connectionQuality: {
|
|
18
|
+
latency: null,
|
|
19
|
+
effectiveType: null,
|
|
20
|
+
isSlow: false,
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const useOfflineStore = createStore<OfflineState, OfflineActions>({
|
|
25
|
+
name: 'offline-store',
|
|
26
|
+
initialState,
|
|
27
|
+
persist: false,
|
|
28
|
+
actions: (set, get) => ({
|
|
29
|
+
updateNetworkState: (state: NetworkState) => {
|
|
30
|
+
const isConnected = state.isConnected ?? false;
|
|
31
|
+
const isReachable = state.isInternetReachable ?? null;
|
|
32
|
+
const isOnline = isConnected && (isReachable !== false);
|
|
33
|
+
const currentState = get();
|
|
34
|
+
|
|
35
|
+
const hasChanged =
|
|
36
|
+
currentState.isOnline !== isOnline ||
|
|
37
|
+
currentState.connectionType !== state.type ||
|
|
38
|
+
currentState.isInternetReachable !== isReachable;
|
|
39
|
+
|
|
40
|
+
if (hasChanged) {
|
|
41
|
+
set({
|
|
42
|
+
isOnline,
|
|
43
|
+
isOffline: !isConnected || (isReachable === false),
|
|
44
|
+
connectionType: state.type,
|
|
45
|
+
isInternetReachable: isReachable,
|
|
46
|
+
lastOnlineAt: (isConnected && isReachable !== false) ? new Date() : currentState.lastOnlineAt,
|
|
47
|
+
lastOfflineAt: (!isConnected || isReachable === false) ? new Date() : currentState.lastOfflineAt,
|
|
48
|
+
connectionQuality: currentState.connectionQuality,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
setOnline: () => {
|
|
54
|
+
const currentState = get();
|
|
55
|
+
if (!currentState.isOnline) {
|
|
56
|
+
set({
|
|
57
|
+
isOnline: true,
|
|
58
|
+
isOffline: false,
|
|
59
|
+
lastOnlineAt: new Date(),
|
|
60
|
+
connectionQuality: currentState.connectionQuality,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
setOffline: () => {
|
|
66
|
+
const currentState = get();
|
|
67
|
+
if (currentState.isOnline) {
|
|
68
|
+
set({
|
|
69
|
+
isOnline: false,
|
|
70
|
+
isOffline: true,
|
|
71
|
+
lastOfflineAt: new Date(),
|
|
72
|
+
connectionQuality: currentState.connectionQuality,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
reset: () => set(initialState),
|
|
78
|
+
}),
|
|
79
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connection Health Check
|
|
3
|
+
* Periodically checks real internet connectivity by pinging a reliable endpoint
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { OfflineConfig } from '../../types';
|
|
7
|
+
|
|
8
|
+
const DEFAULT_HEALTH_CHECK_URL = 'https://www.google.com/favicon.ico';
|
|
9
|
+
const DEFAULT_TIMEOUT = 5000;
|
|
10
|
+
|
|
11
|
+
export class HealthCheck {
|
|
12
|
+
private intervalId: ReturnType<typeof setInterval> | null = null;
|
|
13
|
+
private isChecking = false;
|
|
14
|
+
private config: Required<OfflineConfig>;
|
|
15
|
+
|
|
16
|
+
constructor(config: OfflineConfig = {}) {
|
|
17
|
+
this.config = {
|
|
18
|
+
persist: config.persist ?? false,
|
|
19
|
+
debug: config.debug ?? false,
|
|
20
|
+
healthCheckInterval: config.healthCheckInterval ?? 0,
|
|
21
|
+
healthCheckTimeout: config.healthCheckTimeout ?? DEFAULT_TIMEOUT,
|
|
22
|
+
healthCheckUrl: config.healthCheckUrl ?? DEFAULT_HEALTH_CHECK_URL,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Perform a single health check
|
|
28
|
+
*/
|
|
29
|
+
async check(): Promise<boolean> {
|
|
30
|
+
if (this.isChecking) {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
this.isChecking = true;
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const controller = new AbortController();
|
|
38
|
+
const timeoutId = setTimeout(() => controller.abort(), this.config.healthCheckTimeout);
|
|
39
|
+
|
|
40
|
+
const response = await fetch(this.config.healthCheckUrl, {
|
|
41
|
+
method: 'HEAD',
|
|
42
|
+
signal: controller.signal,
|
|
43
|
+
cache: 'no-cache',
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
clearTimeout(timeoutId);
|
|
47
|
+
|
|
48
|
+
const isHealthy = response.ok;
|
|
49
|
+
|
|
50
|
+
if (this.config.debug) {
|
|
51
|
+
// eslint-disable-next-line no-console
|
|
52
|
+
console.log('[HealthCheck] Result:', isHealthy ? 'HEALTHY' : 'UNHEALTHY');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return isHealthy;
|
|
56
|
+
} catch (error) {
|
|
57
|
+
if (this.config.debug) {
|
|
58
|
+
// eslint-disable-next-line no-console
|
|
59
|
+
console.warn('[HealthCheck] Failed:', error);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return false;
|
|
63
|
+
} finally {
|
|
64
|
+
this.isChecking = false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Start periodic health checks
|
|
70
|
+
*/
|
|
71
|
+
start(callback: (isHealthy: boolean) => void): void {
|
|
72
|
+
if (this.config.healthCheckInterval === 0) {
|
|
73
|
+
if (this.config.debug) {
|
|
74
|
+
// eslint-disable-next-line no-console
|
|
75
|
+
console.log('[HealthCheck] Disabled (interval = 0)');
|
|
76
|
+
}
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (this.intervalId) {
|
|
81
|
+
this.stop();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (this.config.debug) {
|
|
85
|
+
// eslint-disable-next-line no-console
|
|
86
|
+
console.log('[HealthCheck] Starting (interval:', this.config.healthCheckInterval + 'ms)');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
this.intervalId = setInterval(async () => {
|
|
90
|
+
const isHealthy = await this.check();
|
|
91
|
+
callback(isHealthy);
|
|
92
|
+
}, this.config.healthCheckInterval);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Stop health checks
|
|
97
|
+
*/
|
|
98
|
+
stop(): void {
|
|
99
|
+
if (this.intervalId) {
|
|
100
|
+
clearInterval(this.intervalId);
|
|
101
|
+
this.intervalId = null;
|
|
102
|
+
|
|
103
|
+
if (this.config.debug) {
|
|
104
|
+
// eslint-disable-next-line no-console
|
|
105
|
+
console.log('[HealthCheck] Stopped');
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Clean up resources
|
|
112
|
+
*/
|
|
113
|
+
destroy(): void {
|
|
114
|
+
this.stop();
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OfflineBanner Component
|
|
3
|
+
*
|
|
4
|
+
* Displays a banner when the device is offline.
|
|
5
|
+
* Fully customizable for use across 100+ apps.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```tsx
|
|
9
|
+
* import { OfflineBanner, useOffline } from '@umituz/react-native-offline';
|
|
10
|
+
*
|
|
11
|
+
* const App = () => {
|
|
12
|
+
* const { isOffline } = useOffline();
|
|
13
|
+
*
|
|
14
|
+
* return (
|
|
15
|
+
* <>
|
|
16
|
+
* <OfflineBanner
|
|
17
|
+
* visible={isOffline}
|
|
18
|
+
* message="No internet connection"
|
|
19
|
+
* backgroundColor="#FF6B6B"
|
|
20
|
+
* />
|
|
21
|
+
* <YourContent />
|
|
22
|
+
* </>
|
|
23
|
+
* );
|
|
24
|
+
* };
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import React, { useEffect, useRef, memo } from 'react';
|
|
29
|
+
import {
|
|
30
|
+
View,
|
|
31
|
+
Text,
|
|
32
|
+
Animated,
|
|
33
|
+
StyleSheet,
|
|
34
|
+
type ViewStyle,
|
|
35
|
+
type TextStyle,
|
|
36
|
+
} from 'react-native';
|
|
37
|
+
|
|
38
|
+
export interface OfflineBannerProps {
|
|
39
|
+
/** Whether the banner is visible */
|
|
40
|
+
visible: boolean;
|
|
41
|
+
/** Message to display */
|
|
42
|
+
message?: string;
|
|
43
|
+
/** Background color of the banner */
|
|
44
|
+
backgroundColor?: string;
|
|
45
|
+
/** Text color */
|
|
46
|
+
textColor?: string;
|
|
47
|
+
/** Icon to display (emoji or custom element) */
|
|
48
|
+
icon?: string | React.ReactNode;
|
|
49
|
+
/** Position of the banner */
|
|
50
|
+
position?: 'top' | 'bottom';
|
|
51
|
+
/** Custom container style */
|
|
52
|
+
style?: ViewStyle;
|
|
53
|
+
/** Custom text style */
|
|
54
|
+
textStyle?: TextStyle;
|
|
55
|
+
/** Animation duration in ms */
|
|
56
|
+
animationDuration?: number;
|
|
57
|
+
/** Height of the banner */
|
|
58
|
+
height?: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const DEFAULT_HEIGHT = 44;
|
|
62
|
+
const DEFAULT_ANIMATION_DURATION = 300;
|
|
63
|
+
|
|
64
|
+
export const OfflineBanner: React.FC<OfflineBannerProps> = memo(({
|
|
65
|
+
visible,
|
|
66
|
+
message = 'No internet connection',
|
|
67
|
+
backgroundColor = '#FF6B6B',
|
|
68
|
+
textColor = '#FFFFFF',
|
|
69
|
+
icon = '📡',
|
|
70
|
+
position = 'top',
|
|
71
|
+
style,
|
|
72
|
+
textStyle,
|
|
73
|
+
animationDuration = DEFAULT_ANIMATION_DURATION,
|
|
74
|
+
height = DEFAULT_HEIGHT,
|
|
75
|
+
}) => {
|
|
76
|
+
const animatedValue = useRef(new Animated.Value(visible ? 1 : 0)).current;
|
|
77
|
+
const isFirstRender = useRef(true);
|
|
78
|
+
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
if (isFirstRender.current) {
|
|
81
|
+
isFirstRender.current = false;
|
|
82
|
+
animatedValue.setValue(visible ? 1 : 0);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
Animated.timing(animatedValue, {
|
|
87
|
+
toValue: visible ? 1 : 0,
|
|
88
|
+
duration: animationDuration,
|
|
89
|
+
useNativeDriver: false,
|
|
90
|
+
}).start();
|
|
91
|
+
}, [visible, animatedValue, animationDuration]);
|
|
92
|
+
|
|
93
|
+
const translateY = animatedValue.interpolate({
|
|
94
|
+
inputRange: [0, 1],
|
|
95
|
+
outputRange: [position === 'top' ? -height : height, 0],
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const animatedHeight = animatedValue.interpolate({
|
|
99
|
+
inputRange: [0, 1],
|
|
100
|
+
outputRange: [0, height],
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const opacity = animatedValue.interpolate({
|
|
104
|
+
inputRange: [0, 0.5, 1],
|
|
105
|
+
outputRange: [0, 0.8, 1],
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const positionStyle: ViewStyle = position === 'top'
|
|
109
|
+
? { top: 0 }
|
|
110
|
+
: { bottom: 0 };
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<Animated.View
|
|
114
|
+
style={[
|
|
115
|
+
styles.container,
|
|
116
|
+
positionStyle,
|
|
117
|
+
{
|
|
118
|
+
backgroundColor,
|
|
119
|
+
height: animatedHeight,
|
|
120
|
+
transform: [{ translateY }],
|
|
121
|
+
opacity,
|
|
122
|
+
},
|
|
123
|
+
style,
|
|
124
|
+
]}
|
|
125
|
+
pointerEvents={visible ? 'auto' : 'none'}
|
|
126
|
+
>
|
|
127
|
+
<View style={styles.content}>
|
|
128
|
+
{typeof icon === 'string' ? (
|
|
129
|
+
<Text style={styles.icon}>{icon}</Text>
|
|
130
|
+
) : (
|
|
131
|
+
icon
|
|
132
|
+
)}
|
|
133
|
+
<Text
|
|
134
|
+
style={[
|
|
135
|
+
styles.message,
|
|
136
|
+
{ color: textColor },
|
|
137
|
+
textStyle,
|
|
138
|
+
]}
|
|
139
|
+
numberOfLines={1}
|
|
140
|
+
>
|
|
141
|
+
{message}
|
|
142
|
+
</Text>
|
|
143
|
+
</View>
|
|
144
|
+
</Animated.View>
|
|
145
|
+
);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
OfflineBanner.displayName = 'OfflineBanner';
|
|
149
|
+
|
|
150
|
+
const styles = StyleSheet.create({
|
|
151
|
+
container: {
|
|
152
|
+
position: 'absolute',
|
|
153
|
+
left: 0,
|
|
154
|
+
right: 0,
|
|
155
|
+
zIndex: 9999,
|
|
156
|
+
overflow: 'hidden',
|
|
157
|
+
},
|
|
158
|
+
content: {
|
|
159
|
+
flex: 1,
|
|
160
|
+
flexDirection: 'row',
|
|
161
|
+
alignItems: 'center',
|
|
162
|
+
justifyContent: 'center',
|
|
163
|
+
paddingHorizontal: 16,
|
|
164
|
+
gap: 8,
|
|
165
|
+
},
|
|
166
|
+
icon: {
|
|
167
|
+
fontSize: 16,
|
|
168
|
+
},
|
|
169
|
+
message: {
|
|
170
|
+
fontSize: 14,
|
|
171
|
+
fontWeight: '500',
|
|
172
|
+
},
|
|
173
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useOffline Hook
|
|
3
|
+
* Primary hook for accessing offline state in components
|
|
4
|
+
* Automatically subscribes to network changes via expo-network
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { useEffect, useCallback, useRef } from 'react';
|
|
8
|
+
import * as Network from 'expo-network';
|
|
9
|
+
import type { NetworkState as ExpoNetworkState } from 'expo-network';
|
|
10
|
+
import type { NetworkState, OfflineConfig } from '../../types';
|
|
11
|
+
import { useOfflineStore } from '../../infrastructure/storage/OfflineStore';
|
|
12
|
+
import { networkEvents } from '../../infrastructure/events/NetworkEvents';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Convert expo-network state to our internal format
|
|
16
|
+
*/
|
|
17
|
+
const toNetworkState = (state: ExpoNetworkState): NetworkState => ({
|
|
18
|
+
type: state.type?.toString() ?? 'unknown',
|
|
19
|
+
isConnected: state.isConnected ?? false,
|
|
20
|
+
isInternetReachable: state.isInternetReachable ?? null,
|
|
21
|
+
details: null,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
let globalConfig: OfflineConfig = {};
|
|
25
|
+
|
|
26
|
+
export const configureOffline = (config: OfflineConfig): void => {
|
|
27
|
+
globalConfig = config;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const useOffline = (config?: OfflineConfig) => {
|
|
31
|
+
const store = useOfflineStore();
|
|
32
|
+
const isInitialized = useRef(false);
|
|
33
|
+
const previousStateRef = useRef<NetworkState | null>(null);
|
|
34
|
+
const mergedConfig = { ...globalConfig, ...config };
|
|
35
|
+
|
|
36
|
+
const handleNetworkStateChange = useCallback((state: ExpoNetworkState) => {
|
|
37
|
+
const networkState = toNetworkState(state);
|
|
38
|
+
const wasOnline = previousStateRef.current?.isConnected ?? false;
|
|
39
|
+
const isNowOnline = networkState.isConnected ?? false;
|
|
40
|
+
|
|
41
|
+
store.updateNetworkState(networkState);
|
|
42
|
+
|
|
43
|
+
if (wasOnline !== isNowOnline) {
|
|
44
|
+
if (isNowOnline) {
|
|
45
|
+
networkEvents.emit('online', networkState);
|
|
46
|
+
} else {
|
|
47
|
+
networkEvents.emit('offline', networkState);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
networkEvents.emit('change', networkState);
|
|
52
|
+
previousStateRef.current = networkState;
|
|
53
|
+
}, [store]);
|
|
54
|
+
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (isInitialized.current) return;
|
|
57
|
+
|
|
58
|
+
Network.getNetworkStateAsync()
|
|
59
|
+
.then((state: ExpoNetworkState) => {
|
|
60
|
+
handleNetworkStateChange(state);
|
|
61
|
+
isInitialized.current = true;
|
|
62
|
+
})
|
|
63
|
+
.catch((error: Error) => {
|
|
64
|
+
if (__DEV__ || mergedConfig.debug) {
|
|
65
|
+
// eslint-disable-next-line no-console
|
|
66
|
+
console.error('[react-native-offline] Failed to fetch network state:', error);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const subscription = Network.addNetworkStateListener(handleNetworkStateChange);
|
|
71
|
+
|
|
72
|
+
return () => {
|
|
73
|
+
subscription.remove();
|
|
74
|
+
isInitialized.current = false;
|
|
75
|
+
};
|
|
76
|
+
}, [handleNetworkStateChange, mergedConfig.debug]);
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
isOnline: store.isOnline,
|
|
80
|
+
isOffline: store.isOffline,
|
|
81
|
+
connectionType: store.connectionType,
|
|
82
|
+
isInternetReachable: store.isInternetReachable,
|
|
83
|
+
lastOnlineAt: store.lastOnlineAt,
|
|
84
|
+
lastOfflineAt: store.lastOfflineAt,
|
|
85
|
+
connectionQuality: store.connectionQuality,
|
|
86
|
+
hasConnection: store.isOnline,
|
|
87
|
+
hasInternet: store.isOnline && store.isInternetReachable !== false,
|
|
88
|
+
};
|
|
89
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useOfflineState Hook
|
|
3
|
+
* Raw store access without NetInfo subscription
|
|
4
|
+
* Use this for selectors and performance optimization
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { OfflineStore } from '../../types';
|
|
8
|
+
import { useOfflineStore } from '../../infrastructure/storage/OfflineStore';
|
|
9
|
+
|
|
10
|
+
export const useOfflineState = <T = OfflineStore>(
|
|
11
|
+
selector?: (state: OfflineStore) => T
|
|
12
|
+
): T => {
|
|
13
|
+
const store = useOfflineStore();
|
|
14
|
+
return selector ? selector(store) : (store as T);
|
|
15
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useOfflineWithMutations Hook
|
|
3
|
+
* Enhanced version that calls a callback when coming back online
|
|
4
|
+
* Useful for syncing data or resuming operations
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { useEffect, useRef, useCallback } from 'react';
|
|
8
|
+
import { useOffline } from './useOffline';
|
|
9
|
+
|
|
10
|
+
export const useOfflineWithMutations = (onOnline: () => Promise<void>) => {
|
|
11
|
+
const offlineState = useOffline();
|
|
12
|
+
const previousOnlineRef = useRef(offlineState.isOnline);
|
|
13
|
+
const isProcessingRef = useRef(false);
|
|
14
|
+
|
|
15
|
+
const handleOnlineCallback = useCallback(async () => {
|
|
16
|
+
if (isProcessingRef.current) return;
|
|
17
|
+
|
|
18
|
+
isProcessingRef.current = true;
|
|
19
|
+
try {
|
|
20
|
+
if (__DEV__) {
|
|
21
|
+
// eslint-disable-next-line no-console
|
|
22
|
+
console.log('[react-native-offline] Executing online callback');
|
|
23
|
+
}
|
|
24
|
+
await onOnline();
|
|
25
|
+
} catch (error) {
|
|
26
|
+
if (__DEV__) {
|
|
27
|
+
// eslint-disable-next-line no-console
|
|
28
|
+
console.error('[react-native-offline] Online callback failed:', error);
|
|
29
|
+
}
|
|
30
|
+
} finally {
|
|
31
|
+
isProcessingRef.current = false;
|
|
32
|
+
}
|
|
33
|
+
}, [onOnline]);
|
|
34
|
+
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
const wasOffline = !previousOnlineRef.current;
|
|
37
|
+
const isNowOnline = offlineState.isOnline;
|
|
38
|
+
|
|
39
|
+
if (wasOffline && isNowOnline && !isProcessingRef.current) {
|
|
40
|
+
handleOnlineCallback();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
previousOnlineRef.current = offlineState.isOnline;
|
|
44
|
+
}, [offlineState.isOnline, handleOnlineCallback]);
|
|
45
|
+
|
|
46
|
+
return offlineState;
|
|
47
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Network Provider
|
|
3
|
+
*
|
|
4
|
+
* Initializes network connectivity listener at app startup.
|
|
5
|
+
* Wrap your app with this provider to enable offline detection.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```tsx
|
|
9
|
+
* import { NetworkProvider } from '@umituz/react-native-offline';
|
|
10
|
+
*
|
|
11
|
+
* const App = () => (
|
|
12
|
+
* <NetworkProvider>
|
|
13
|
+
* <YourApp />
|
|
14
|
+
* </NetworkProvider>
|
|
15
|
+
* );
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import React, { ReactNode, memo } from 'react';
|
|
20
|
+
import { useOffline } from '../hooks/useOffline';
|
|
21
|
+
|
|
22
|
+
interface NetworkProviderProps {
|
|
23
|
+
children: ReactNode;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const NetworkProvider: React.FC<NetworkProviderProps> = memo(({ children }) => {
|
|
27
|
+
// Initialize NetInfo listener - updates Zustand store
|
|
28
|
+
useOffline();
|
|
29
|
+
|
|
30
|
+
return <>{children}</>;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
NetworkProvider.displayName = 'NetworkProvider';
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type Definitions
|
|
3
|
+
* Network connectivity state types for React Native apps
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Network state from expo-network
|
|
8
|
+
*/
|
|
9
|
+
export interface NetworkState {
|
|
10
|
+
readonly type: string;
|
|
11
|
+
readonly isConnected: boolean | null;
|
|
12
|
+
readonly isInternetReachable: boolean | null;
|
|
13
|
+
readonly details: unknown;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Configuration for offline behavior
|
|
18
|
+
*/
|
|
19
|
+
export interface OfflineConfig {
|
|
20
|
+
/** Enable persistent storage of network state */
|
|
21
|
+
persist?: boolean;
|
|
22
|
+
/** Enable debug logging */
|
|
23
|
+
debug?: boolean;
|
|
24
|
+
/** Health check interval in ms (0 = disabled) */
|
|
25
|
+
healthCheckInterval?: number;
|
|
26
|
+
/** Health check timeout in ms */
|
|
27
|
+
healthCheckTimeout?: number;
|
|
28
|
+
/** Health check URL to ping */
|
|
29
|
+
healthCheckUrl?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Connection quality metrics
|
|
34
|
+
*/
|
|
35
|
+
export interface ConnectionQuality {
|
|
36
|
+
readonly latency: number | null;
|
|
37
|
+
readonly effectiveType: '2g' | '3g' | '4g' | '5g' | 'unknown' | null;
|
|
38
|
+
readonly isSlow: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Offline state representation
|
|
43
|
+
*/
|
|
44
|
+
export interface OfflineState {
|
|
45
|
+
readonly isOnline: boolean;
|
|
46
|
+
readonly isOffline: boolean;
|
|
47
|
+
readonly connectionType: string | null;
|
|
48
|
+
readonly isInternetReachable: boolean | null;
|
|
49
|
+
readonly lastOnlineAt: Date | null;
|
|
50
|
+
readonly lastOfflineAt: Date | null;
|
|
51
|
+
readonly connectionQuality: ConnectionQuality;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Offline store actions
|
|
56
|
+
*/
|
|
57
|
+
export interface OfflineActions {
|
|
58
|
+
readonly updateNetworkState: (state: NetworkState) => void;
|
|
59
|
+
readonly setOnline: () => void;
|
|
60
|
+
readonly setOffline: () => void;
|
|
61
|
+
readonly reset: () => void;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Offline store with state and actions
|
|
66
|
+
*/
|
|
67
|
+
export interface OfflineStore extends OfflineState, OfflineActions {}
|