@buivietphi/skill-mobile-mt 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/AGENTS.md +392 -0
- package/README.md +224 -0
- package/SKILL.md +1048 -0
- package/android/android-native.md +208 -0
- package/bin/install.mjs +199 -0
- package/flutter/flutter.md +246 -0
- package/ios/ios-native.md +182 -0
- package/package.json +50 -0
- package/react-native/react-native.md +743 -0
- package/shared/agent-rules-template.md +343 -0
- package/shared/anti-patterns.md +407 -0
- package/shared/bug-detection.md +71 -0
- package/shared/claude-md-template.md +125 -0
- package/shared/code-review.md +121 -0
- package/shared/common-pitfalls.md +117 -0
- package/shared/document-analysis.md +167 -0
- package/shared/error-recovery.md +467 -0
- package/shared/observability.md +688 -0
- package/shared/performance-prediction.md +210 -0
- package/shared/platform-excellence.md +159 -0
- package/shared/prompt-engineering.md +677 -0
- package/shared/release-checklist.md +82 -0
- package/shared/version-management.md +509 -0
|
@@ -0,0 +1,743 @@
|
|
|
1
|
+
# React Native — Production Patterns
|
|
2
|
+
|
|
3
|
+
> Battle-tested patterns from production React Native apps.
|
|
4
|
+
> Supports: React Native CLI + Expo (managed & bare)
|
|
5
|
+
> Language: TypeScript (recommended) + JavaScript
|
|
6
|
+
> State: Redux, MobX, Zustand, TanStack Query, Apollo/GraphQL
|
|
7
|
+
> Navigation: @react-navigation (CLI) / expo-router (Expo)
|
|
8
|
+
> Networking: axios / fetch
|
|
9
|
+
> Package manager: yarn (preferred), npm, pnpm, bun
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Table of Contents
|
|
14
|
+
|
|
15
|
+
1. [Clean Architecture](#clean-architecture)
|
|
16
|
+
2. [Expo Project Structure](#expo-project-structure)
|
|
17
|
+
3. [State Management](#state-management)
|
|
18
|
+
4. [Navigation](#navigation)
|
|
19
|
+
5. [API Layer](#api-layer)
|
|
20
|
+
6. [Push Notifications](#push-notifications)
|
|
21
|
+
7. [Expo SDK Modules](#expo-sdk-modules)
|
|
22
|
+
8. [Build & Deploy](#build--deploy)
|
|
23
|
+
9. [Common Libraries](#common-libraries)
|
|
24
|
+
10. [Multi-Tenant Pattern](#multi-tenant--workspace-pattern)
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## Clean Architecture
|
|
29
|
+
|
|
30
|
+
### React Native CLI
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
src/
|
|
34
|
+
├── app/ # App entry, providers, root navigator
|
|
35
|
+
├── domain/ # Business logic (pure, no dependencies)
|
|
36
|
+
│ ├── entities/ # Core business models
|
|
37
|
+
│ ├── usecases/ # Business rules
|
|
38
|
+
│ └── repositories/ # Repository interfaces (contracts)
|
|
39
|
+
├── data/ # Data layer (implements domain interfaces)
|
|
40
|
+
│ ├── repositories/ # Repository implementations
|
|
41
|
+
│ ├── datasources/ # Remote (API) + Local (AsyncStorage, DB)
|
|
42
|
+
│ └── models/ # DTOs, mappers to domain entities
|
|
43
|
+
├── presentation/ # UI layer
|
|
44
|
+
│ ├── screens/ # Screen components (1 per route)
|
|
45
|
+
│ ├── components/
|
|
46
|
+
│ │ ├── common/ # Shared: Button, Input, Card, Modal
|
|
47
|
+
│ │ └── [feature]/ # Feature-specific components
|
|
48
|
+
│ ├── hooks/ # Custom hooks (useAuth, useApi)
|
|
49
|
+
│ └── navigation/ # Stack, Tab, Drawer navigators
|
|
50
|
+
├── services/ # External services (analytics, notifications)
|
|
51
|
+
├── utils/ # Helpers, formatters, validators
|
|
52
|
+
├── constants/ # Colors, spacing, API URLs
|
|
53
|
+
├── types/ # TypeScript types (if TS project)
|
|
54
|
+
└── assets/ # Images, fonts
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Dependency Rule
|
|
58
|
+
```
|
|
59
|
+
presentation/ → domain/ ← data/
|
|
60
|
+
|
|
61
|
+
UI depends on Domain. Data depends on Domain.
|
|
62
|
+
Domain depends on NOTHING.
|
|
63
|
+
Never import data/ from presentation/ directly.
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## Expo Project Structure
|
|
69
|
+
|
|
70
|
+
### Expo Router (file-based routing)
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
app/ # File-based routes (expo-router)
|
|
74
|
+
├── _layout.tsx # Root layout (providers, fonts, splash)
|
|
75
|
+
├── (tabs)/ # Tab group
|
|
76
|
+
│ ├── _layout.tsx # Tab navigator config
|
|
77
|
+
│ ├── index.tsx # Home tab (/)
|
|
78
|
+
│ ├── search.tsx # Search tab (/search)
|
|
79
|
+
│ └── profile.tsx # Profile tab (/profile)
|
|
80
|
+
├── (auth)/ # Auth group (no tabs)
|
|
81
|
+
│ ├── _layout.tsx # Stack navigator for auth
|
|
82
|
+
│ ├── login.tsx # /login
|
|
83
|
+
│ └── register.tsx # /register
|
|
84
|
+
├── product/
|
|
85
|
+
│ └── [id].tsx # Dynamic route: /product/123
|
|
86
|
+
├── +not-found.tsx # 404 screen
|
|
87
|
+
└── +html.tsx # Custom HTML wrapper (web)
|
|
88
|
+
src/
|
|
89
|
+
├── domain/ # Same clean architecture as CLI
|
|
90
|
+
├── data/
|
|
91
|
+
├── components/
|
|
92
|
+
├── hooks/
|
|
93
|
+
├── services/
|
|
94
|
+
├── utils/
|
|
95
|
+
├── constants/
|
|
96
|
+
└── types/
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### app.config.js / app.json
|
|
100
|
+
|
|
101
|
+
```javascript
|
|
102
|
+
// app.config.js — dynamic config (recommended over app.json)
|
|
103
|
+
export default ({ config }) => ({
|
|
104
|
+
...config,
|
|
105
|
+
name: "MyApp",
|
|
106
|
+
slug: "my-app",
|
|
107
|
+
version: "1.0.0",
|
|
108
|
+
scheme: "myapp", // Deep linking
|
|
109
|
+
orientation: "portrait",
|
|
110
|
+
icon: "./assets/icon.png",
|
|
111
|
+
splash: { image: "./assets/splash.png", resizeMode: "contain" },
|
|
112
|
+
ios: {
|
|
113
|
+
bundleIdentifier: "com.company.myapp",
|
|
114
|
+
supportsTablet: true,
|
|
115
|
+
},
|
|
116
|
+
android: {
|
|
117
|
+
package: "com.company.myapp",
|
|
118
|
+
adaptiveIcon: { foregroundImage: "./assets/adaptive-icon.png" },
|
|
119
|
+
},
|
|
120
|
+
plugins: [
|
|
121
|
+
"expo-router",
|
|
122
|
+
"expo-secure-store",
|
|
123
|
+
["expo-camera", { cameraPermission: "Allow camera access" }],
|
|
124
|
+
["expo-location", { locationAlwaysPermission: "Allow location" }],
|
|
125
|
+
],
|
|
126
|
+
extra: {
|
|
127
|
+
API_URL: process.env.API_URL || "https://api.example.com",
|
|
128
|
+
eas: { projectId: "your-project-id" },
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### Root Layout (Expo Router)
|
|
134
|
+
|
|
135
|
+
```tsx
|
|
136
|
+
// app/_layout.tsx
|
|
137
|
+
import { Slot, SplashScreen } from 'expo-router';
|
|
138
|
+
import { useFonts } from 'expo-font';
|
|
139
|
+
import { useEffect } from 'react';
|
|
140
|
+
|
|
141
|
+
SplashScreen.preventAutoHideAsync();
|
|
142
|
+
|
|
143
|
+
export default function RootLayout() {
|
|
144
|
+
const [fontsLoaded] = useFonts({ 'Inter': require('../assets/fonts/Inter.ttf') });
|
|
145
|
+
|
|
146
|
+
useEffect(() => {
|
|
147
|
+
if (fontsLoaded) SplashScreen.hideAsync();
|
|
148
|
+
}, [fontsLoaded]);
|
|
149
|
+
|
|
150
|
+
if (!fontsLoaded) return null;
|
|
151
|
+
|
|
152
|
+
return <Slot />;
|
|
153
|
+
}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
## State Management
|
|
159
|
+
|
|
160
|
+
### Redux Pattern (RTK)
|
|
161
|
+
|
|
162
|
+
```typescript
|
|
163
|
+
// presentation/store/slices/authSlice.ts
|
|
164
|
+
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
|
|
165
|
+
|
|
166
|
+
export const login = createAsyncThunk('auth/login', async (creds, { rejectWithValue }) => {
|
|
167
|
+
try {
|
|
168
|
+
const response = await authApi.login(creds);
|
|
169
|
+
return response.data;
|
|
170
|
+
} catch (error) {
|
|
171
|
+
return rejectWithValue(error.response?.data);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const authSlice = createSlice({
|
|
176
|
+
name: 'auth',
|
|
177
|
+
initialState: { user: null, token: null, loading: false, error: null },
|
|
178
|
+
reducers: {
|
|
179
|
+
logout: (state) => { state.user = null; state.token = null; },
|
|
180
|
+
},
|
|
181
|
+
extraReducers: (builder) => {
|
|
182
|
+
builder
|
|
183
|
+
.addCase(login.pending, (state) => { state.loading = true; state.error = null; })
|
|
184
|
+
.addCase(login.fulfilled, (state, action) => {
|
|
185
|
+
state.loading = false;
|
|
186
|
+
state.user = action.payload.user;
|
|
187
|
+
state.token = action.payload.token;
|
|
188
|
+
})
|
|
189
|
+
.addCase(login.rejected, (state, action) => {
|
|
190
|
+
state.loading = false;
|
|
191
|
+
state.error = action.payload;
|
|
192
|
+
});
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
### Zustand Pattern (lightweight alternative)
|
|
198
|
+
|
|
199
|
+
```typescript
|
|
200
|
+
// stores/useAuthStore.ts
|
|
201
|
+
import { create } from 'zustand';
|
|
202
|
+
import { persist, createJSONStorage } from 'zustand/middleware';
|
|
203
|
+
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
204
|
+
|
|
205
|
+
interface AuthState {
|
|
206
|
+
user: User | null;
|
|
207
|
+
token: string | null;
|
|
208
|
+
loading: boolean;
|
|
209
|
+
login: (creds: Credentials) => Promise<void>;
|
|
210
|
+
logout: () => void;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export const useAuthStore = create<AuthState>()(
|
|
214
|
+
persist(
|
|
215
|
+
(set) => ({
|
|
216
|
+
user: null,
|
|
217
|
+
token: null,
|
|
218
|
+
loading: false,
|
|
219
|
+
|
|
220
|
+
login: async (creds) => {
|
|
221
|
+
set({ loading: true });
|
|
222
|
+
try {
|
|
223
|
+
const res = await authApi.login(creds);
|
|
224
|
+
set({ user: res.data.user, token: res.data.token, loading: false });
|
|
225
|
+
} catch (error) {
|
|
226
|
+
set({ loading: false });
|
|
227
|
+
throw error;
|
|
228
|
+
}
|
|
229
|
+
},
|
|
230
|
+
|
|
231
|
+
logout: () => set({ user: null, token: null }),
|
|
232
|
+
}),
|
|
233
|
+
{ name: 'auth-storage', storage: createJSONStorage(() => AsyncStorage) }
|
|
234
|
+
)
|
|
235
|
+
);
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
**JavaScript version:**
|
|
239
|
+
```javascript
|
|
240
|
+
// stores/useAuthStore.js
|
|
241
|
+
import { create } from 'zustand';
|
|
242
|
+
import { persist, createJSONStorage } from 'zustand/middleware';
|
|
243
|
+
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
244
|
+
|
|
245
|
+
export const useAuthStore = create(
|
|
246
|
+
persist(
|
|
247
|
+
(set) => ({
|
|
248
|
+
user: null,
|
|
249
|
+
token: null,
|
|
250
|
+
loading: false,
|
|
251
|
+
|
|
252
|
+
login: async (creds) => {
|
|
253
|
+
set({ loading: true });
|
|
254
|
+
try {
|
|
255
|
+
const res = await authApi.login(creds);
|
|
256
|
+
set({ user: res.data.user, token: res.data.token, loading: false });
|
|
257
|
+
} catch (error) {
|
|
258
|
+
set({ loading: false });
|
|
259
|
+
throw error;
|
|
260
|
+
}
|
|
261
|
+
},
|
|
262
|
+
|
|
263
|
+
logout: () => set({ user: null, token: null }),
|
|
264
|
+
}),
|
|
265
|
+
{ name: 'auth-storage', storage: createJSONStorage(() => AsyncStorage) }
|
|
266
|
+
)
|
|
267
|
+
);
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
### TanStack Query (server state)
|
|
271
|
+
|
|
272
|
+
```typescript
|
|
273
|
+
// hooks/useProducts.ts
|
|
274
|
+
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
275
|
+
|
|
276
|
+
export function useProducts() {
|
|
277
|
+
return useQuery({
|
|
278
|
+
queryKey: ['products'],
|
|
279
|
+
queryFn: () => api.get('/products').then(r => r.data),
|
|
280
|
+
staleTime: 5 * 60 * 1000, // 5 min cache
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export function useCreateProduct() {
|
|
285
|
+
const queryClient = useQueryClient();
|
|
286
|
+
return useMutation({
|
|
287
|
+
mutationFn: (product) => api.post('/products', product),
|
|
288
|
+
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['products'] }),
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// In component:
|
|
293
|
+
function ProductList() {
|
|
294
|
+
const { data, isLoading, error, refetch } = useProducts();
|
|
295
|
+
if (isLoading) return <LoadingSkeleton />;
|
|
296
|
+
if (error) return <ErrorView message={error.message} onRetry={refetch} />;
|
|
297
|
+
if (!data?.length) return <EmptyState />;
|
|
298
|
+
return <FlatList data={data} keyExtractor={item => item.id} renderItem={...} />;
|
|
299
|
+
}
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
### MobX Pattern
|
|
303
|
+
|
|
304
|
+
```typescript
|
|
305
|
+
import { makeAutoObservable, runInAction } from 'mobx';
|
|
306
|
+
|
|
307
|
+
class AuthStore {
|
|
308
|
+
user = null;
|
|
309
|
+
token = null;
|
|
310
|
+
loading = false;
|
|
311
|
+
|
|
312
|
+
constructor() { makeAutoObservable(this); }
|
|
313
|
+
|
|
314
|
+
async login(creds) {
|
|
315
|
+
this.loading = true;
|
|
316
|
+
try {
|
|
317
|
+
const res = await authApi.login(creds);
|
|
318
|
+
runInAction(() => {
|
|
319
|
+
this.user = res.data.user;
|
|
320
|
+
this.token = res.data.token;
|
|
321
|
+
this.loading = false;
|
|
322
|
+
});
|
|
323
|
+
} catch (error) {
|
|
324
|
+
runInAction(() => { this.loading = false; });
|
|
325
|
+
throw error;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
---
|
|
332
|
+
|
|
333
|
+
## Navigation
|
|
334
|
+
|
|
335
|
+
### @react-navigation (React Native CLI)
|
|
336
|
+
|
|
337
|
+
```typescript
|
|
338
|
+
import { NavigationContainer } from '@react-navigation/native';
|
|
339
|
+
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
|
340
|
+
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
|
341
|
+
|
|
342
|
+
const Stack = createNativeStackNavigator();
|
|
343
|
+
const Tab = createBottomTabNavigator();
|
|
344
|
+
|
|
345
|
+
function MainTabs() {
|
|
346
|
+
return (
|
|
347
|
+
<Tab.Navigator>
|
|
348
|
+
<Tab.Screen name="Home" component={HomeScreen} />
|
|
349
|
+
<Tab.Screen name="Profile" component={ProfileScreen} />
|
|
350
|
+
</Tab.Navigator>
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function AppNavigator() {
|
|
355
|
+
const isLoggedIn = useSelector(state => state.auth.token);
|
|
356
|
+
return (
|
|
357
|
+
<NavigationContainer>
|
|
358
|
+
<Stack.Navigator screenOptions={{ headerShown: false }}>
|
|
359
|
+
{isLoggedIn ? (
|
|
360
|
+
<Stack.Screen name="Main" component={MainTabs} />
|
|
361
|
+
) : (
|
|
362
|
+
<Stack.Screen name="Auth" component={AuthStack} />
|
|
363
|
+
)}
|
|
364
|
+
</Stack.Navigator>
|
|
365
|
+
</NavigationContainer>
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
### expo-router (Expo)
|
|
371
|
+
|
|
372
|
+
```tsx
|
|
373
|
+
// app/(tabs)/_layout.tsx
|
|
374
|
+
import { Tabs } from 'expo-router';
|
|
375
|
+
import { Ionicons } from '@expo/vector-icons';
|
|
376
|
+
|
|
377
|
+
export default function TabLayout() {
|
|
378
|
+
return (
|
|
379
|
+
<Tabs screenOptions={{ tabBarActiveTintColor: '#007AFF' }}>
|
|
380
|
+
<Tabs.Screen name="index" options={{
|
|
381
|
+
title: 'Home',
|
|
382
|
+
tabBarIcon: ({ color }) => <Ionicons name="home" size={24} color={color} />,
|
|
383
|
+
}} />
|
|
384
|
+
<Tabs.Screen name="profile" options={{
|
|
385
|
+
title: 'Profile',
|
|
386
|
+
tabBarIcon: ({ color }) => <Ionicons name="person" size={24} color={color} />,
|
|
387
|
+
}} />
|
|
388
|
+
</Tabs>
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Navigation in expo-router:
|
|
393
|
+
import { router } from 'expo-router';
|
|
394
|
+
|
|
395
|
+
// Push screen
|
|
396
|
+
router.push('/product/123');
|
|
397
|
+
// Replace screen
|
|
398
|
+
router.replace('/login');
|
|
399
|
+
// Go back
|
|
400
|
+
router.back();
|
|
401
|
+
// Navigate with params
|
|
402
|
+
router.push({ pathname: '/product/[id]', params: { id: '123' } });
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
**JavaScript version:**
|
|
406
|
+
```jsx
|
|
407
|
+
// app/(tabs)/_layout.js
|
|
408
|
+
import { Tabs } from 'expo-router';
|
|
409
|
+
import { Ionicons } from '@expo/vector-icons';
|
|
410
|
+
|
|
411
|
+
export default function TabLayout() {
|
|
412
|
+
return (
|
|
413
|
+
<Tabs screenOptions={{ tabBarActiveTintColor: '#007AFF' }}>
|
|
414
|
+
<Tabs.Screen name="index" options={{
|
|
415
|
+
title: 'Home',
|
|
416
|
+
tabBarIcon: ({ color }) => <Ionicons name="home" size={24} color={color} />,
|
|
417
|
+
}} />
|
|
418
|
+
<Tabs.Screen name="profile" options={{
|
|
419
|
+
title: 'Profile',
|
|
420
|
+
tabBarIcon: ({ color }) => <Ionicons name="person" size={24} color={color} />,
|
|
421
|
+
}} />
|
|
422
|
+
</Tabs>
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
### Auth-Protected Routes (expo-router)
|
|
428
|
+
|
|
429
|
+
```tsx
|
|
430
|
+
// app/(auth)/_layout.tsx
|
|
431
|
+
import { Redirect, Stack } from 'expo-router';
|
|
432
|
+
import { useAuthStore } from '../../stores/useAuthStore';
|
|
433
|
+
|
|
434
|
+
export default function AuthLayout() {
|
|
435
|
+
const token = useAuthStore(s => s.token);
|
|
436
|
+
if (token) return <Redirect href="/(tabs)" />;
|
|
437
|
+
return <Stack screenOptions={{ headerShown: false }} />;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// app/(tabs)/_layout.tsx
|
|
441
|
+
import { Redirect, Tabs } from 'expo-router';
|
|
442
|
+
import { useAuthStore } from '../../stores/useAuthStore';
|
|
443
|
+
|
|
444
|
+
export default function TabLayout() {
|
|
445
|
+
const token = useAuthStore(s => s.token);
|
|
446
|
+
if (!token) return <Redirect href="/(auth)/login" />;
|
|
447
|
+
return <Tabs>...</Tabs>;
|
|
448
|
+
}
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
---
|
|
452
|
+
|
|
453
|
+
## API Layer
|
|
454
|
+
|
|
455
|
+
### axios (with interceptors)
|
|
456
|
+
|
|
457
|
+
```typescript
|
|
458
|
+
import axios from 'axios';
|
|
459
|
+
|
|
460
|
+
const api = axios.create({ baseURL: API_URL, timeout: 15000 });
|
|
461
|
+
|
|
462
|
+
api.interceptors.request.use((config) => {
|
|
463
|
+
const token = store.getState().auth.token;
|
|
464
|
+
if (token) config.headers.Authorization = `Bearer ${token}`;
|
|
465
|
+
return config;
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
api.interceptors.response.use(
|
|
469
|
+
(res) => res,
|
|
470
|
+
(error) => {
|
|
471
|
+
if (error.response?.status === 401) {
|
|
472
|
+
store.dispatch(logout());
|
|
473
|
+
}
|
|
474
|
+
return Promise.reject(error);
|
|
475
|
+
}
|
|
476
|
+
);
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
### fetch (no dependencies)
|
|
480
|
+
|
|
481
|
+
```javascript
|
|
482
|
+
// services/api.js — works with both TS and JS
|
|
483
|
+
const API_URL = 'https://api.example.com';
|
|
484
|
+
|
|
485
|
+
async function request(endpoint, options = {}) {
|
|
486
|
+
const token = getToken(); // from secure storage
|
|
487
|
+
const response = await fetch(`${API_URL}${endpoint}`, {
|
|
488
|
+
...options,
|
|
489
|
+
headers: {
|
|
490
|
+
'Content-Type': 'application/json',
|
|
491
|
+
...(token && { Authorization: `Bearer ${token}` }),
|
|
492
|
+
...options.headers,
|
|
493
|
+
},
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
if (response.status === 401) {
|
|
497
|
+
handleLogout();
|
|
498
|
+
throw new Error('Unauthorized');
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
if (!response.ok) {
|
|
502
|
+
const error = await response.json().catch(() => ({}));
|
|
503
|
+
throw new Error(error.message || `HTTP ${response.status}`);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
return response.json();
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
export const api = {
|
|
510
|
+
get: (endpoint) => request(endpoint),
|
|
511
|
+
post: (endpoint, data) => request(endpoint, { method: 'POST', body: JSON.stringify(data) }),
|
|
512
|
+
put: (endpoint, data) => request(endpoint, { method: 'PUT', body: JSON.stringify(data) }),
|
|
513
|
+
delete: (endpoint) => request(endpoint, { method: 'DELETE' }),
|
|
514
|
+
};
|
|
515
|
+
```
|
|
516
|
+
|
|
517
|
+
---
|
|
518
|
+
|
|
519
|
+
## Push Notifications
|
|
520
|
+
|
|
521
|
+
### Firebase (React Native CLI)
|
|
522
|
+
|
|
523
|
+
```typescript
|
|
524
|
+
import messaging from '@react-native-firebase/messaging';
|
|
525
|
+
|
|
526
|
+
async function requestNotificationPermission() {
|
|
527
|
+
const authStatus = await messaging().requestPermission();
|
|
528
|
+
if (authStatus === messaging.AuthorizationStatus.AUTHORIZED) {
|
|
529
|
+
const token = await messaging().getToken();
|
|
530
|
+
await api.post('/devices/register', { fcm_token: token });
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
### expo-notifications (Expo)
|
|
536
|
+
|
|
537
|
+
```typescript
|
|
538
|
+
import * as Notifications from 'expo-notifications';
|
|
539
|
+
import * as Device from 'expo-device';
|
|
540
|
+
import Constants from 'expo-constants';
|
|
541
|
+
|
|
542
|
+
Notifications.setNotificationHandler({
|
|
543
|
+
handleNotification: async () => ({
|
|
544
|
+
shouldShowAlert: true,
|
|
545
|
+
shouldPlaySound: true,
|
|
546
|
+
shouldSetBadge: true,
|
|
547
|
+
}),
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
async function registerForPushNotifications() {
|
|
551
|
+
if (!Device.isDevice) return null; // Must be physical device
|
|
552
|
+
|
|
553
|
+
const { status: existing } = await Notifications.getPermissionsAsync();
|
|
554
|
+
let finalStatus = existing;
|
|
555
|
+
|
|
556
|
+
if (existing !== 'granted') {
|
|
557
|
+
const { status } = await Notifications.requestPermissionsAsync();
|
|
558
|
+
finalStatus = status;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
if (finalStatus !== 'granted') return null;
|
|
562
|
+
|
|
563
|
+
const token = (await Notifications.getExpoPushTokenAsync({
|
|
564
|
+
projectId: Constants.expoConfig?.extra?.eas?.projectId,
|
|
565
|
+
})).data;
|
|
566
|
+
|
|
567
|
+
await api.post('/devices/register', { push_token: token });
|
|
568
|
+
return token;
|
|
569
|
+
}
|
|
570
|
+
```
|
|
571
|
+
|
|
572
|
+
---
|
|
573
|
+
|
|
574
|
+
## Expo SDK Modules
|
|
575
|
+
|
|
576
|
+
| Purpose | Package | Usage |
|
|
577
|
+
|---------|---------|-------|
|
|
578
|
+
| Secure Storage | `expo-secure-store` | Tokens, secrets (Keychain/Keystore) |
|
|
579
|
+
| Camera | `expo-camera` | Camera access with permissions |
|
|
580
|
+
| Location | `expo-location` | GPS, geofencing |
|
|
581
|
+
| Image Picker | `expo-image-picker` | Photo library + camera |
|
|
582
|
+
| File System | `expo-file-system` | Read/write local files |
|
|
583
|
+
| Notifications | `expo-notifications` | Push + local notifications |
|
|
584
|
+
| Auth Session | `expo-auth-session` | OAuth (Google, Apple, etc.) |
|
|
585
|
+
| Linking | `expo-linking` | Deep links |
|
|
586
|
+
| Haptics | `expo-haptics` | Vibration feedback |
|
|
587
|
+
| Splash Screen | `expo-splash-screen` | Control splash visibility |
|
|
588
|
+
| Font | `expo-font` | Custom fonts |
|
|
589
|
+
| Constants | `expo-constants` | App config values |
|
|
590
|
+
|
|
591
|
+
### Expo + Native Modules
|
|
592
|
+
|
|
593
|
+
```
|
|
594
|
+
⚠️ If you need a native module not in Expo SDK:
|
|
595
|
+
1. Use "expo prebuild" to eject to bare workflow
|
|
596
|
+
2. Or use a config plugin: plugins: ["my-native-package"]
|
|
597
|
+
3. Or use EAS Build (handles native deps in cloud)
|
|
598
|
+
⛔ NEVER run "expo eject" — deprecated. Use "expo prebuild" instead.
|
|
599
|
+
```
|
|
600
|
+
|
|
601
|
+
---
|
|
602
|
+
|
|
603
|
+
## Build & Deploy
|
|
604
|
+
|
|
605
|
+
### React Native CLI
|
|
606
|
+
|
|
607
|
+
```
|
|
608
|
+
scripts:
|
|
609
|
+
dev: react-native run-android --variant=devDebug
|
|
610
|
+
staging: react-native run-android --variant=stagingDebug
|
|
611
|
+
prod: react-native run-android --variant=prodRelease
|
|
612
|
+
```
|
|
613
|
+
|
|
614
|
+
### Expo (EAS Build)
|
|
615
|
+
|
|
616
|
+
```bash
|
|
617
|
+
# Install EAS CLI
|
|
618
|
+
npm install -g eas-cli
|
|
619
|
+
|
|
620
|
+
# Configure (creates eas.json)
|
|
621
|
+
eas build:configure
|
|
622
|
+
|
|
623
|
+
# Build for stores
|
|
624
|
+
eas build --platform ios --profile production
|
|
625
|
+
eas build --platform android --profile production
|
|
626
|
+
|
|
627
|
+
# Build for testing (internal distribution)
|
|
628
|
+
eas build --platform all --profile preview
|
|
629
|
+
|
|
630
|
+
# OTA update (no store review needed)
|
|
631
|
+
eas update --branch production --message "Fix: cart total bug"
|
|
632
|
+
|
|
633
|
+
# Submit to stores
|
|
634
|
+
eas submit --platform ios
|
|
635
|
+
eas submit --platform android
|
|
636
|
+
```
|
|
637
|
+
|
|
638
|
+
### eas.json
|
|
639
|
+
|
|
640
|
+
```json
|
|
641
|
+
{
|
|
642
|
+
"build": {
|
|
643
|
+
"development": {
|
|
644
|
+
"developmentClient": true,
|
|
645
|
+
"distribution": "internal",
|
|
646
|
+
"env": { "API_URL": "https://dev-api.example.com" }
|
|
647
|
+
},
|
|
648
|
+
"preview": {
|
|
649
|
+
"distribution": "internal",
|
|
650
|
+
"env": { "API_URL": "https://staging-api.example.com" }
|
|
651
|
+
},
|
|
652
|
+
"production": {
|
|
653
|
+
"env": { "API_URL": "https://api.example.com" }
|
|
654
|
+
}
|
|
655
|
+
},
|
|
656
|
+
"submit": {
|
|
657
|
+
"production": {
|
|
658
|
+
"ios": { "appleId": "you@example.com", "ascAppId": "123456789" },
|
|
659
|
+
"android": { "serviceAccountKeyPath": "./google-sa-key.json" }
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
```
|
|
664
|
+
|
|
665
|
+
---
|
|
666
|
+
|
|
667
|
+
## Common Libraries
|
|
668
|
+
|
|
669
|
+
### React Native CLI
|
|
670
|
+
|
|
671
|
+
| Purpose | Library |
|
|
672
|
+
|---------|---------|
|
|
673
|
+
| HTTP | axios |
|
|
674
|
+
| State | redux + redux-persist |
|
|
675
|
+
| State | mobx + mobx-react |
|
|
676
|
+
| State | zustand |
|
|
677
|
+
| Server State | @tanstack/react-query |
|
|
678
|
+
| State | @apollo/client |
|
|
679
|
+
| Nav | @react-navigation |
|
|
680
|
+
| Anim | react-native-reanimated |
|
|
681
|
+
| Gestures | react-native-gesture-handler |
|
|
682
|
+
| Camera | react-native-camera |
|
|
683
|
+
| Maps | react-native-maps |
|
|
684
|
+
| Push | @react-native-firebase/messaging |
|
|
685
|
+
| Image | react-native-image-picker |
|
|
686
|
+
| Fast Image | react-native-fast-image |
|
|
687
|
+
| Bottom Sheet | @gorhom/bottom-sheet |
|
|
688
|
+
| Lottie | lottie-react-native |
|
|
689
|
+
| Socket | socket.io-client |
|
|
690
|
+
| Forms | react-hook-form + zod |
|
|
691
|
+
| Styled | nativewind (Tailwind) / styled-components |
|
|
692
|
+
|
|
693
|
+
### Expo
|
|
694
|
+
|
|
695
|
+
| Purpose | Library |
|
|
696
|
+
|---------|---------|
|
|
697
|
+
| HTTP | axios / fetch (built-in) |
|
|
698
|
+
| State | zustand (recommended) / redux |
|
|
699
|
+
| Server State | @tanstack/react-query |
|
|
700
|
+
| Nav | expo-router |
|
|
701
|
+
| Camera | expo-camera |
|
|
702
|
+
| Location | expo-location |
|
|
703
|
+
| Image | expo-image-picker |
|
|
704
|
+
| Fast Image | expo-image |
|
|
705
|
+
| Push | expo-notifications |
|
|
706
|
+
| Storage | expo-secure-store |
|
|
707
|
+
| Auth | expo-auth-session |
|
|
708
|
+
| Icons | @expo/vector-icons |
|
|
709
|
+
| Fonts | expo-font + expo-splash-screen |
|
|
710
|
+
| Forms | react-hook-form + zod |
|
|
711
|
+
| Styled | nativewind (Tailwind) / tamagui |
|
|
712
|
+
|
|
713
|
+
---
|
|
714
|
+
|
|
715
|
+
## Multi-Tenant / Workspace Pattern
|
|
716
|
+
|
|
717
|
+
```
|
|
718
|
+
workspace/
|
|
719
|
+
├── tenants/
|
|
720
|
+
│ ├── tenant-a/config.js # Tenant-specific config
|
|
721
|
+
│ ├── tenant-b/config.js
|
|
722
|
+
│ └── tenant-c/config.js
|
|
723
|
+
├── source/
|
|
724
|
+
│ ├── src/ # Shared code
|
|
725
|
+
│ └── config/ # Base config
|
|
726
|
+
└── package.json # Yarn workspaces, nohoist for RN deps
|
|
727
|
+
```
|
|
728
|
+
|
|
729
|
+
---
|
|
730
|
+
|
|
731
|
+
## Quick Reference
|
|
732
|
+
|
|
733
|
+
| Project Type | Navigation | Build | Push | Storage |
|
|
734
|
+
|-------------|-----------|-------|------|---------|
|
|
735
|
+
| RN CLI + TS | @react-navigation | Gradle/Xcode | Firebase | react-native-keychain |
|
|
736
|
+
| RN CLI + JS | @react-navigation | Gradle/Xcode | Firebase | react-native-keychain |
|
|
737
|
+
| Expo + TS | expo-router | EAS Build | expo-notifications | expo-secure-store |
|
|
738
|
+
| Expo + JS | expo-router | EAS Build | expo-notifications | expo-secure-store |
|
|
739
|
+
|
|
740
|
+
> **Zustand + TanStack Query** is the modern lightweight choice.
|
|
741
|
+
> **Redux** for complex apps with many cross-feature state dependencies.
|
|
742
|
+
> **MobX** for complex observable state patterns.
|
|
743
|
+
> **Apollo** for GraphQL backends.
|