@croacroa/react-native-template 1.0.0
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/.env.example +18 -0
- package/.eslintrc.js +55 -0
- package/.github/workflows/ci.yml +184 -0
- package/.github/workflows/eas-build.yml +55 -0
- package/.github/workflows/eas-update.yml +50 -0
- package/.gitignore +62 -0
- package/.prettierrc +11 -0
- package/.storybook/main.ts +28 -0
- package/.storybook/preview.tsx +30 -0
- package/CHANGELOG.md +106 -0
- package/CONTRIBUTING.md +377 -0
- package/README.md +399 -0
- package/__tests__/components/Button.test.tsx +74 -0
- package/__tests__/hooks/useAuth.test.tsx +499 -0
- package/__tests__/services/api.test.ts +535 -0
- package/__tests__/utils/cn.test.ts +39 -0
- package/app/(auth)/_layout.tsx +36 -0
- package/app/(auth)/home.tsx +117 -0
- package/app/(auth)/profile.tsx +152 -0
- package/app/(auth)/settings.tsx +147 -0
- package/app/(public)/_layout.tsx +21 -0
- package/app/(public)/forgot-password.tsx +127 -0
- package/app/(public)/login.tsx +120 -0
- package/app/(public)/onboarding.tsx +5 -0
- package/app/(public)/register.tsx +139 -0
- package/app/_layout.tsx +97 -0
- package/app/index.tsx +21 -0
- package/app.config.ts +72 -0
- package/assets/images/.gitkeep +7 -0
- package/assets/images/adaptive-icon.png +0 -0
- package/assets/images/favicon.png +0 -0
- package/assets/images/icon.png +0 -0
- package/assets/images/notification-icon.png +0 -0
- package/assets/images/splash.png +0 -0
- package/babel.config.js +10 -0
- package/components/ErrorBoundary.tsx +169 -0
- package/components/forms/FormInput.tsx +78 -0
- package/components/forms/index.ts +1 -0
- package/components/onboarding/OnboardingScreen.tsx +370 -0
- package/components/onboarding/index.ts +2 -0
- package/components/ui/AnimatedButton.tsx +156 -0
- package/components/ui/AnimatedCard.tsx +108 -0
- package/components/ui/Avatar.tsx +316 -0
- package/components/ui/Badge.tsx +416 -0
- package/components/ui/BottomSheet.tsx +307 -0
- package/components/ui/Button.stories.tsx +115 -0
- package/components/ui/Button.tsx +104 -0
- package/components/ui/Card.stories.tsx +84 -0
- package/components/ui/Card.tsx +32 -0
- package/components/ui/Checkbox.tsx +261 -0
- package/components/ui/Input.stories.tsx +106 -0
- package/components/ui/Input.tsx +117 -0
- package/components/ui/Modal.tsx +98 -0
- package/components/ui/OptimizedImage.tsx +369 -0
- package/components/ui/Select.tsx +240 -0
- package/components/ui/Skeleton.tsx +180 -0
- package/components/ui/index.ts +18 -0
- package/constants/config.ts +54 -0
- package/docs/adr/001-state-management.md +79 -0
- package/docs/adr/002-styling-approach.md +130 -0
- package/docs/adr/003-data-fetching.md +155 -0
- package/docs/adr/004-auth-adapter-pattern.md +144 -0
- package/docs/adr/README.md +78 -0
- package/eas.json +47 -0
- package/global.css +10 -0
- package/hooks/index.ts +25 -0
- package/hooks/useApi.ts +236 -0
- package/hooks/useAuth.tsx +290 -0
- package/hooks/useBiometrics.ts +295 -0
- package/hooks/useDeepLinking.ts +256 -0
- package/hooks/useNotifications.ts +138 -0
- package/hooks/useOffline.ts +69 -0
- package/hooks/usePerformance.ts +434 -0
- package/hooks/useTheme.tsx +85 -0
- package/hooks/useUpdates.ts +358 -0
- package/i18n/index.ts +77 -0
- package/i18n/locales/en.json +101 -0
- package/i18n/locales/fr.json +101 -0
- package/jest.config.js +32 -0
- package/maestro/README.md +113 -0
- package/maestro/config.yaml +35 -0
- package/maestro/flows/login.yaml +62 -0
- package/maestro/flows/navigation.yaml +68 -0
- package/maestro/flows/offline.yaml +60 -0
- package/maestro/flows/register.yaml +94 -0
- package/metro.config.js +6 -0
- package/nativewind-env.d.ts +1 -0
- package/package.json +170 -0
- package/scripts/init.ps1 +162 -0
- package/scripts/init.sh +174 -0
- package/services/analytics.ts +428 -0
- package/services/api.ts +340 -0
- package/services/authAdapter.ts +333 -0
- package/services/index.ts +22 -0
- package/services/queryClient.ts +97 -0
- package/services/sentry.ts +131 -0
- package/services/storage.ts +82 -0
- package/stores/appStore.ts +54 -0
- package/stores/index.ts +2 -0
- package/stores/notificationStore.ts +40 -0
- package/tailwind.config.js +47 -0
- package/tsconfig.json +26 -0
- package/types/index.ts +42 -0
- package/types/user.ts +63 -0
- package/utils/accessibility.ts +446 -0
- package/utils/cn.ts +14 -0
- package/utils/index.ts +43 -0
- package/utils/toast.ts +113 -0
- package/utils/validation.ts +67 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { useEffect, useCallback } from "react";
|
|
2
|
+
import * as Linking from "expo-linking";
|
|
3
|
+
import { router } from "expo-router";
|
|
4
|
+
|
|
5
|
+
// Define your app's deep link routes
|
|
6
|
+
type DeepLinkRoute =
|
|
7
|
+
| { path: "login"; params?: never }
|
|
8
|
+
| { path: "register"; params?: never }
|
|
9
|
+
| { path: "reset-password"; params: { token: string } }
|
|
10
|
+
| { path: "profile"; params?: { userId?: string } }
|
|
11
|
+
| { path: "settings"; params?: never }
|
|
12
|
+
| { path: "post"; params: { postId: string } };
|
|
13
|
+
|
|
14
|
+
interface ParsedDeepLink {
|
|
15
|
+
route: DeepLinkRoute | null;
|
|
16
|
+
rawUrl: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Parse a deep link URL into a route and params
|
|
21
|
+
*/
|
|
22
|
+
function parseDeepLink(url: string): ParsedDeepLink {
|
|
23
|
+
try {
|
|
24
|
+
const parsed = Linking.parse(url);
|
|
25
|
+
const { path, queryParams } = parsed;
|
|
26
|
+
|
|
27
|
+
if (!path) {
|
|
28
|
+
return { route: null, rawUrl: url };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Map URL paths to app routes
|
|
32
|
+
switch (path) {
|
|
33
|
+
case "login":
|
|
34
|
+
return { route: { path: "login" }, rawUrl: url };
|
|
35
|
+
|
|
36
|
+
case "register":
|
|
37
|
+
case "signup":
|
|
38
|
+
return { route: { path: "register" }, rawUrl: url };
|
|
39
|
+
|
|
40
|
+
case "reset-password":
|
|
41
|
+
if (queryParams?.token && typeof queryParams.token === "string") {
|
|
42
|
+
return {
|
|
43
|
+
route: {
|
|
44
|
+
path: "reset-password",
|
|
45
|
+
params: { token: queryParams.token },
|
|
46
|
+
},
|
|
47
|
+
rawUrl: url,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
return { route: null, rawUrl: url };
|
|
51
|
+
|
|
52
|
+
case "profile":
|
|
53
|
+
return {
|
|
54
|
+
route: {
|
|
55
|
+
path: "profile",
|
|
56
|
+
params: queryParams?.userId
|
|
57
|
+
? { userId: String(queryParams.userId) }
|
|
58
|
+
: undefined,
|
|
59
|
+
},
|
|
60
|
+
rawUrl: url,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
case "settings":
|
|
64
|
+
return { route: { path: "settings" }, rawUrl: url };
|
|
65
|
+
|
|
66
|
+
case "post":
|
|
67
|
+
if (queryParams?.id && typeof queryParams.id === "string") {
|
|
68
|
+
return {
|
|
69
|
+
route: { path: "post", params: { postId: queryParams.id } },
|
|
70
|
+
rawUrl: url,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
return { route: null, rawUrl: url };
|
|
74
|
+
|
|
75
|
+
default: {
|
|
76
|
+
// Try to handle paths like /post/123
|
|
77
|
+
const pathParts = path.split("/");
|
|
78
|
+
if (pathParts[0] === "post" && pathParts[1]) {
|
|
79
|
+
return {
|
|
80
|
+
route: { path: "post", params: { postId: pathParts[1] } },
|
|
81
|
+
rawUrl: url,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
if (pathParts[0] === "profile" && pathParts[1]) {
|
|
85
|
+
return {
|
|
86
|
+
route: { path: "profile", params: { userId: pathParts[1] } },
|
|
87
|
+
rawUrl: url,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
return { route: null, rawUrl: url };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
} catch (error) {
|
|
94
|
+
console.error("Failed to parse deep link:", error);
|
|
95
|
+
return { route: null, rawUrl: url };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Navigate to a deep link route
|
|
101
|
+
*/
|
|
102
|
+
function navigateToRoute(route: DeepLinkRoute): void {
|
|
103
|
+
switch (route.path) {
|
|
104
|
+
case "login":
|
|
105
|
+
router.replace("/(public)/login");
|
|
106
|
+
break;
|
|
107
|
+
|
|
108
|
+
case "register":
|
|
109
|
+
router.replace("/(public)/register");
|
|
110
|
+
break;
|
|
111
|
+
|
|
112
|
+
case "reset-password":
|
|
113
|
+
// Navigate to forgot password with token
|
|
114
|
+
router.push({
|
|
115
|
+
pathname: "/(public)/forgot-password",
|
|
116
|
+
params: { token: route.params.token },
|
|
117
|
+
});
|
|
118
|
+
break;
|
|
119
|
+
|
|
120
|
+
case "profile":
|
|
121
|
+
if (route.params?.userId) {
|
|
122
|
+
// Navigate to specific user profile
|
|
123
|
+
router.push({
|
|
124
|
+
pathname: "/(auth)/profile",
|
|
125
|
+
params: { userId: route.params.userId },
|
|
126
|
+
});
|
|
127
|
+
} else {
|
|
128
|
+
// Navigate to own profile
|
|
129
|
+
router.push("/(auth)/profile");
|
|
130
|
+
}
|
|
131
|
+
break;
|
|
132
|
+
|
|
133
|
+
case "settings":
|
|
134
|
+
router.push("/(auth)/settings");
|
|
135
|
+
break;
|
|
136
|
+
|
|
137
|
+
case "post":
|
|
138
|
+
// You'll need to create this route
|
|
139
|
+
router.push({
|
|
140
|
+
pathname: "/(auth)/post/[id]" as const,
|
|
141
|
+
params: { id: route.params.postId },
|
|
142
|
+
} as Parameters<typeof router.push>[0]);
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
interface UseDeepLinkingOptions {
|
|
148
|
+
/**
|
|
149
|
+
* Called when a deep link is received but couldn't be parsed
|
|
150
|
+
*/
|
|
151
|
+
onUnknownLink?: (url: string) => void;
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Called before navigating to allow custom handling
|
|
155
|
+
* Return false to prevent default navigation
|
|
156
|
+
*/
|
|
157
|
+
onBeforeNavigate?: (route: DeepLinkRoute) => boolean | void;
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Whether deep linking is enabled
|
|
161
|
+
* @default true
|
|
162
|
+
*/
|
|
163
|
+
enabled?: boolean;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Hook to handle deep links in your app
|
|
168
|
+
*
|
|
169
|
+
* @example
|
|
170
|
+
* ```tsx
|
|
171
|
+
* function App() {
|
|
172
|
+
* useDeepLinking({
|
|
173
|
+
* onUnknownLink: (url) => console.log('Unknown link:', url),
|
|
174
|
+
* onBeforeNavigate: (route) => {
|
|
175
|
+
* // Custom validation before navigating
|
|
176
|
+
* if (route.path === 'profile' && !isAuthenticated) {
|
|
177
|
+
* router.push('/login');
|
|
178
|
+
* return false; // Prevent default navigation
|
|
179
|
+
* }
|
|
180
|
+
* },
|
|
181
|
+
* });
|
|
182
|
+
*
|
|
183
|
+
* return <App />;
|
|
184
|
+
* }
|
|
185
|
+
* ```
|
|
186
|
+
*/
|
|
187
|
+
export function useDeepLinking(options: UseDeepLinkingOptions = {}): void {
|
|
188
|
+
const { onUnknownLink, onBeforeNavigate, enabled = true } = options;
|
|
189
|
+
|
|
190
|
+
const handleDeepLink = useCallback(
|
|
191
|
+
(event: { url: string }) => {
|
|
192
|
+
if (!enabled) return;
|
|
193
|
+
|
|
194
|
+
const { route, rawUrl } = parseDeepLink(event.url);
|
|
195
|
+
|
|
196
|
+
if (!route) {
|
|
197
|
+
onUnknownLink?.(rawUrl);
|
|
198
|
+
console.log("Unknown deep link:", rawUrl);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Allow custom handling before navigation
|
|
203
|
+
const shouldNavigate = onBeforeNavigate?.(route);
|
|
204
|
+
if (shouldNavigate === false) {
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
navigateToRoute(route);
|
|
209
|
+
},
|
|
210
|
+
[enabled, onUnknownLink, onBeforeNavigate]
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
useEffect(() => {
|
|
214
|
+
if (!enabled) return;
|
|
215
|
+
|
|
216
|
+
// Handle deep links when app is already open
|
|
217
|
+
const subscription = Linking.addEventListener("url", handleDeepLink);
|
|
218
|
+
|
|
219
|
+
// Handle deep link that opened the app
|
|
220
|
+
Linking.getInitialURL().then((url) => {
|
|
221
|
+
if (url) {
|
|
222
|
+
handleDeepLink({ url });
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
return () => {
|
|
227
|
+
subscription.remove();
|
|
228
|
+
};
|
|
229
|
+
}, [enabled, handleDeepLink]);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Get the app's deep link URL prefix
|
|
234
|
+
*/
|
|
235
|
+
export function getDeepLinkPrefix(): string {
|
|
236
|
+
return Linking.createURL("/");
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Create a deep link URL for the app
|
|
241
|
+
*
|
|
242
|
+
* @example
|
|
243
|
+
* ```ts
|
|
244
|
+
* const url = createDeepLink('profile', { userId: '123' });
|
|
245
|
+
* // Returns: yourapp://profile?userId=123
|
|
246
|
+
* ```
|
|
247
|
+
*/
|
|
248
|
+
export function createDeepLink(
|
|
249
|
+
path: string,
|
|
250
|
+
params?: Record<string, string>
|
|
251
|
+
): string {
|
|
252
|
+
return Linking.createURL(path, { queryParams: params });
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export { parseDeepLink, navigateToRoute };
|
|
256
|
+
export type { DeepLinkRoute, ParsedDeepLink };
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
import { Platform } from "react-native";
|
|
3
|
+
import * as Notifications from "expo-notifications";
|
|
4
|
+
import * as Device from "expo-device";
|
|
5
|
+
import Constants from "expo-constants";
|
|
6
|
+
|
|
7
|
+
import { useNotificationStore } from "@/stores/notificationStore";
|
|
8
|
+
|
|
9
|
+
// Configure how notifications should be handled when app is in foreground
|
|
10
|
+
Notifications.setNotificationHandler({
|
|
11
|
+
handleNotification: async () => ({
|
|
12
|
+
shouldShowAlert: true,
|
|
13
|
+
shouldPlaySound: true,
|
|
14
|
+
shouldSetBadge: true,
|
|
15
|
+
}),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export function useNotifications() {
|
|
19
|
+
const notificationListener = useRef<Notifications.Subscription>();
|
|
20
|
+
const responseListener = useRef<Notifications.Subscription>();
|
|
21
|
+
const { setPushToken, setLastNotification } = useNotificationStore();
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
// Listen for incoming notifications
|
|
25
|
+
notificationListener.current =
|
|
26
|
+
Notifications.addNotificationReceivedListener((notification) => {
|
|
27
|
+
setLastNotification(notification);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// Listen for notification interactions
|
|
31
|
+
responseListener.current =
|
|
32
|
+
Notifications.addNotificationResponseReceivedListener((response) => {
|
|
33
|
+
const data = response.notification.request.content.data;
|
|
34
|
+
handleNotificationPress(data);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
return () => {
|
|
38
|
+
if (notificationListener.current) {
|
|
39
|
+
Notifications.removeNotificationSubscription(
|
|
40
|
+
notificationListener.current
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
if (responseListener.current) {
|
|
44
|
+
Notifications.removeNotificationSubscription(responseListener.current);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
}, []);
|
|
48
|
+
|
|
49
|
+
const handleNotificationPress = (data: Record<string, unknown>) => {
|
|
50
|
+
// TODO: Handle notification navigation based on data
|
|
51
|
+
// Example: router.push(data.screen as string);
|
|
52
|
+
console.log("Notification pressed with data:", data);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const registerForPushNotifications = async () => {
|
|
56
|
+
if (!Device.isDevice) {
|
|
57
|
+
console.log("Push notifications require a physical device");
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Check existing permissions
|
|
62
|
+
const { status: existingStatus } =
|
|
63
|
+
await Notifications.getPermissionsAsync();
|
|
64
|
+
let finalStatus = existingStatus;
|
|
65
|
+
|
|
66
|
+
// Request permissions if not granted
|
|
67
|
+
if (existingStatus !== "granted") {
|
|
68
|
+
const { status } = await Notifications.requestPermissionsAsync();
|
|
69
|
+
finalStatus = status;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (finalStatus !== "granted") {
|
|
73
|
+
console.log("Push notification permission not granted");
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Get the push token
|
|
78
|
+
try {
|
|
79
|
+
const projectId = Constants.expoConfig?.extra?.eas?.projectId;
|
|
80
|
+
const token = await Notifications.getExpoPushTokenAsync({
|
|
81
|
+
projectId,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
setPushToken(token.data);
|
|
85
|
+
|
|
86
|
+
// Configure Android channel
|
|
87
|
+
if (Platform.OS === "android") {
|
|
88
|
+
await Notifications.setNotificationChannelAsync("default", {
|
|
89
|
+
name: "Default",
|
|
90
|
+
importance: Notifications.AndroidImportance.MAX,
|
|
91
|
+
vibrationPattern: [0, 250, 250, 250],
|
|
92
|
+
lightColor: "#3b82f6",
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return token.data;
|
|
97
|
+
} catch (error) {
|
|
98
|
+
console.error("Failed to get push token:", error);
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const scheduleLocalNotification = async (
|
|
104
|
+
title: string,
|
|
105
|
+
body: string,
|
|
106
|
+
data?: Record<string, unknown>,
|
|
107
|
+
trigger?: Notifications.NotificationTriggerInput
|
|
108
|
+
) => {
|
|
109
|
+
await Notifications.scheduleNotificationAsync({
|
|
110
|
+
content: {
|
|
111
|
+
title,
|
|
112
|
+
body,
|
|
113
|
+
data: data || {},
|
|
114
|
+
},
|
|
115
|
+
trigger: trigger || null, // null = immediate
|
|
116
|
+
});
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const cancelAllNotifications = async () => {
|
|
120
|
+
await Notifications.cancelAllScheduledNotificationsAsync();
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const getBadgeCount = async () => {
|
|
124
|
+
return Notifications.getBadgeCountAsync();
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const setBadgeCount = async (count: number) => {
|
|
128
|
+
await Notifications.setBadgeCountAsync(count);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
registerForPushNotifications,
|
|
133
|
+
scheduleLocalNotification,
|
|
134
|
+
cancelAllNotifications,
|
|
135
|
+
getBadgeCount,
|
|
136
|
+
setBadgeCount,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import NetInfo, { NetInfoState } from "@react-native-community/netinfo";
|
|
3
|
+
import { onlineManager } from "@tanstack/react-query";
|
|
4
|
+
import { toast } from "@/utils/toast";
|
|
5
|
+
|
|
6
|
+
interface UseOfflineOptions {
|
|
7
|
+
showToast?: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Hook to track online/offline status
|
|
12
|
+
* Integrates with TanStack Query's online manager
|
|
13
|
+
*/
|
|
14
|
+
export function useOffline(options: UseOfflineOptions = {}) {
|
|
15
|
+
const { showToast = true } = options;
|
|
16
|
+
const [isOnline, setIsOnline] = useState(true);
|
|
17
|
+
const [isInitialized, setIsInitialized] = useState(false);
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
// Get initial state
|
|
21
|
+
NetInfo.fetch().then((state) => {
|
|
22
|
+
const online = !!state.isConnected;
|
|
23
|
+
setIsOnline(online);
|
|
24
|
+
setIsInitialized(true);
|
|
25
|
+
onlineManager.setOnline(online);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// Subscribe to changes
|
|
29
|
+
const unsubscribe = NetInfo.addEventListener((state: NetInfoState) => {
|
|
30
|
+
const online = !!state.isConnected;
|
|
31
|
+
const wasOnline = isOnline;
|
|
32
|
+
|
|
33
|
+
setIsOnline(online);
|
|
34
|
+
onlineManager.setOnline(online);
|
|
35
|
+
|
|
36
|
+
// Show toast on status change (after initialization)
|
|
37
|
+
if (showToast && isInitialized) {
|
|
38
|
+
if (!online && wasOnline) {
|
|
39
|
+
toast.info("You're offline", "Some features may be limited");
|
|
40
|
+
} else if (online && !wasOnline) {
|
|
41
|
+
toast.success("Back online", "Syncing data...");
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
return () => unsubscribe();
|
|
47
|
+
}, [isOnline, isInitialized, showToast]);
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
isOnline,
|
|
51
|
+
isOffline: !isOnline,
|
|
52
|
+
isInitialized,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Hook for pending mutations count
|
|
58
|
+
* Useful to show sync indicator
|
|
59
|
+
*/
|
|
60
|
+
export function usePendingMutations() {
|
|
61
|
+
const [pendingCount, setPendingCount] = useState(0);
|
|
62
|
+
|
|
63
|
+
// This would need to be integrated with mutation cache
|
|
64
|
+
// For now, return 0 as a placeholder
|
|
65
|
+
return {
|
|
66
|
+
pendingCount,
|
|
67
|
+
hasPending: pendingCount > 0,
|
|
68
|
+
};
|
|
69
|
+
}
|