@buivietphi/skill-mobile-mt 2.1.0 → 2.2.1
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 +83 -45
- package/README.md +55 -102
- package/SKILL.md +333 -46
- package/package.json +1 -1
- package/shared/code-generation-templates.md +656 -0
- package/shared/code-review.md +8 -8
- package/shared/complex-ui-patterns.md +526 -0
- package/shared/data-flow-patterns.md +422 -0
- package/shared/error-handling.md +394 -0
- package/shared/intent-analysis.md +473 -0
- package/shared/navigation-patterns.md +375 -0
- package/shared/spec-to-code.md +293 -0
- package/shared/testing-patterns.md +428 -0
|
@@ -0,0 +1,656 @@
|
|
|
1
|
+
# Code Generation Templates — Production-Ready Patterns
|
|
2
|
+
|
|
3
|
+
> On-demand module. Loaded when building new features, setting up state management, API clients, or forms.
|
|
4
|
+
> Contains COMPLETE, copy-and-adapt code templates — not snippets.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## State Management Templates
|
|
9
|
+
|
|
10
|
+
### Zustand (React Native) — Advanced Store
|
|
11
|
+
|
|
12
|
+
```typescript
|
|
13
|
+
// stores/useAuthStore.ts
|
|
14
|
+
import { create } from 'zustand';
|
|
15
|
+
import { persist, createJSONStorage } from 'zustand/middleware';
|
|
16
|
+
import { immer } from 'zustand/middleware/immer';
|
|
17
|
+
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
18
|
+
|
|
19
|
+
interface User { id: string; email: string; name: string; role: 'user' | 'admin'; }
|
|
20
|
+
|
|
21
|
+
interface AuthState {
|
|
22
|
+
user: User | null;
|
|
23
|
+
token: string | null;
|
|
24
|
+
refreshToken: string | null;
|
|
25
|
+
isLoading: boolean;
|
|
26
|
+
error: string | null;
|
|
27
|
+
// Actions
|
|
28
|
+
login: (email: string, password: string) => Promise<void>;
|
|
29
|
+
logout: () => void;
|
|
30
|
+
refreshSession: () => Promise<boolean>;
|
|
31
|
+
clearError: () => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const useAuthStore = create<AuthState>()(
|
|
35
|
+
persist(
|
|
36
|
+
immer((set, get) => ({
|
|
37
|
+
user: null,
|
|
38
|
+
token: null,
|
|
39
|
+
refreshToken: null,
|
|
40
|
+
isLoading: false,
|
|
41
|
+
error: null,
|
|
42
|
+
|
|
43
|
+
login: async (email, password) => {
|
|
44
|
+
set(state => { state.isLoading = true; state.error = null; });
|
|
45
|
+
try {
|
|
46
|
+
const res = await authService.login({ email, password });
|
|
47
|
+
set(state => {
|
|
48
|
+
state.user = res.user;
|
|
49
|
+
state.token = res.token;
|
|
50
|
+
state.refreshToken = res.refreshToken;
|
|
51
|
+
state.isLoading = false;
|
|
52
|
+
});
|
|
53
|
+
} catch (e) {
|
|
54
|
+
set(state => {
|
|
55
|
+
state.isLoading = false;
|
|
56
|
+
state.error = e instanceof Error ? e.message : 'Login failed';
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
logout: () => {
|
|
62
|
+
set(state => {
|
|
63
|
+
state.user = null;
|
|
64
|
+
state.token = null;
|
|
65
|
+
state.refreshToken = null;
|
|
66
|
+
});
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
refreshSession: async () => {
|
|
70
|
+
const { refreshToken } = get();
|
|
71
|
+
if (!refreshToken) return false;
|
|
72
|
+
try {
|
|
73
|
+
const res = await authService.refresh(refreshToken);
|
|
74
|
+
set(state => { state.token = res.token; state.refreshToken = res.refreshToken; });
|
|
75
|
+
return true;
|
|
76
|
+
} catch {
|
|
77
|
+
get().logout();
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
clearError: () => set(state => { state.error = null; }),
|
|
83
|
+
})),
|
|
84
|
+
{
|
|
85
|
+
name: 'auth-storage',
|
|
86
|
+
storage: createJSONStorage(() => AsyncStorage),
|
|
87
|
+
partialize: (state) => ({ token: state.token, refreshToken: state.refreshToken, user: state.user }),
|
|
88
|
+
}
|
|
89
|
+
)
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
// Selectors (memoized — prevent re-renders)
|
|
93
|
+
export const useIsLoggedIn = () => useAuthStore(state => !!state.token);
|
|
94
|
+
export const useUserRole = () => useAuthStore(state => state.user?.role);
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Zustand Store Composition
|
|
98
|
+
|
|
99
|
+
```typescript
|
|
100
|
+
// stores/index.ts — composing multiple stores
|
|
101
|
+
export { useAuthStore, useIsLoggedIn } from './useAuthStore';
|
|
102
|
+
export { useCartStore } from './useCartStore';
|
|
103
|
+
export { useSettingsStore } from './useSettingsStore';
|
|
104
|
+
|
|
105
|
+
// Usage: each store is independent, no single god-store
|
|
106
|
+
// Components subscribe to ONLY the store they need
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Redux Toolkit — Entity Adapter for Collections
|
|
110
|
+
|
|
111
|
+
```typescript
|
|
112
|
+
// features/products/productsSlice.ts
|
|
113
|
+
import { createSlice, createAsyncThunk, createEntityAdapter, PayloadAction } from '@reduxjs/toolkit';
|
|
114
|
+
import { Product } from './product.types';
|
|
115
|
+
import { productService } from './productService';
|
|
116
|
+
|
|
117
|
+
const productsAdapter = createEntityAdapter<Product>({
|
|
118
|
+
selectId: (product) => product.id,
|
|
119
|
+
sortComparer: (a, b) => b.createdAt.localeCompare(a.createdAt),
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
interface ProductsExtra { loading: boolean; error: string | null; page: number; hasMore: boolean; }
|
|
123
|
+
|
|
124
|
+
const initialState = productsAdapter.getInitialState<ProductsExtra>({
|
|
125
|
+
loading: false, error: null, page: 1, hasMore: true,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
export const fetchProducts = createAsyncThunk(
|
|
129
|
+
'products/fetch',
|
|
130
|
+
async (page: number, { rejectWithValue }) => {
|
|
131
|
+
try {
|
|
132
|
+
return await productService.getProducts(page);
|
|
133
|
+
} catch (e) {
|
|
134
|
+
return rejectWithValue(e instanceof Error ? e.message : 'Failed to fetch');
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
const productsSlice = createSlice({
|
|
140
|
+
name: 'products',
|
|
141
|
+
initialState,
|
|
142
|
+
reducers: {
|
|
143
|
+
productUpdated: productsAdapter.updateOne,
|
|
144
|
+
productRemoved: productsAdapter.removeOne,
|
|
145
|
+
productsReset: () => initialState,
|
|
146
|
+
},
|
|
147
|
+
extraReducers: (builder) => {
|
|
148
|
+
builder
|
|
149
|
+
.addCase(fetchProducts.pending, (state) => { state.loading = true; state.error = null; })
|
|
150
|
+
.addCase(fetchProducts.fulfilled, (state, action) => {
|
|
151
|
+
state.loading = false;
|
|
152
|
+
state.page += 1;
|
|
153
|
+
state.hasMore = action.payload.length >= 20;
|
|
154
|
+
productsAdapter.upsertMany(state, action.payload);
|
|
155
|
+
})
|
|
156
|
+
.addCase(fetchProducts.rejected, (state, action) => {
|
|
157
|
+
state.loading = false;
|
|
158
|
+
state.error = action.payload as string;
|
|
159
|
+
});
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
export const { productUpdated, productRemoved, productsReset } = productsSlice.actions;
|
|
164
|
+
export default productsSlice.reducer;
|
|
165
|
+
|
|
166
|
+
// Typed selectors
|
|
167
|
+
export const {
|
|
168
|
+
selectAll: selectAllProducts,
|
|
169
|
+
selectById: selectProductById,
|
|
170
|
+
selectTotal: selectProductCount,
|
|
171
|
+
} = productsAdapter.getSelectors((state: RootState) => state.products);
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### Riverpod (Flutter) — Async + Family
|
|
175
|
+
|
|
176
|
+
```dart
|
|
177
|
+
// providers/product_provider.dart
|
|
178
|
+
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
|
179
|
+
part 'product_provider.g.dart';
|
|
180
|
+
|
|
181
|
+
@riverpod
|
|
182
|
+
class ProductList extends _$ProductList {
|
|
183
|
+
int _page = 1;
|
|
184
|
+
bool _hasMore = true;
|
|
185
|
+
|
|
186
|
+
@override
|
|
187
|
+
FutureOr<List<Product>> build() => _fetch();
|
|
188
|
+
|
|
189
|
+
Future<List<Product>> _fetch() async {
|
|
190
|
+
final repo = ref.watch(productRepositoryProvider);
|
|
191
|
+
return repo.getProducts(page: 1);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
Future<void> loadMore() async {
|
|
195
|
+
if (!_hasMore) return;
|
|
196
|
+
final repo = ref.read(productRepositoryProvider);
|
|
197
|
+
final next = await repo.getProducts(page: _page + 1);
|
|
198
|
+
_hasMore = next.length >= 20;
|
|
199
|
+
_page++;
|
|
200
|
+
state = AsyncData([...state.value ?? [], ...next]);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
Future<void> refresh() async {
|
|
204
|
+
_page = 1;
|
|
205
|
+
_hasMore = true;
|
|
206
|
+
ref.invalidateSelf();
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Family provider — parameterized by ID
|
|
211
|
+
@riverpod
|
|
212
|
+
Future<Product> productDetail(ProductDetailRef ref, String id) async {
|
|
213
|
+
final repo = ref.watch(productRepositoryProvider);
|
|
214
|
+
return repo.getProductById(id);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Usage in widget:
|
|
218
|
+
// final products = ref.watch(productListProvider);
|
|
219
|
+
// products.when(data: (list) => ..., error: (e, _) => ..., loading: () => ...);
|
|
220
|
+
//
|
|
221
|
+
// final detail = ref.watch(productDetailProvider('abc-123'));
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
---
|
|
225
|
+
|
|
226
|
+
## API Client Templates
|
|
227
|
+
|
|
228
|
+
### React Native — Axios with Retry + Token Queue
|
|
229
|
+
|
|
230
|
+
```typescript
|
|
231
|
+
// services/api.ts
|
|
232
|
+
import axios, { AxiosError, AxiosRequestConfig, InternalAxiosRequestConfig } from 'axios';
|
|
233
|
+
import { useAuthStore } from '@/stores/useAuthStore';
|
|
234
|
+
|
|
235
|
+
const api = axios.create({
|
|
236
|
+
baseURL: process.env.EXPO_PUBLIC_API_URL,
|
|
237
|
+
timeout: 15000,
|
|
238
|
+
headers: { 'Content-Type': 'application/json' },
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// Token refresh queue — prevents concurrent refresh calls
|
|
242
|
+
let isRefreshing = false;
|
|
243
|
+
let failedQueue: Array<{ resolve: (token: string) => void; reject: (error: Error) => void }> = [];
|
|
244
|
+
|
|
245
|
+
const processQueue = (error: Error | null, token: string | null) => {
|
|
246
|
+
failedQueue.forEach(({ resolve, reject }) => {
|
|
247
|
+
error ? reject(error) : resolve(token!);
|
|
248
|
+
});
|
|
249
|
+
failedQueue = [];
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
// Request interceptor — attach token
|
|
253
|
+
api.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
|
254
|
+
const token = useAuthStore.getState().token;
|
|
255
|
+
if (token) config.headers.Authorization = `Bearer ${token}`;
|
|
256
|
+
return config;
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
// Response interceptor — auto-refresh on 401
|
|
260
|
+
api.interceptors.response.use(
|
|
261
|
+
(response) => response,
|
|
262
|
+
async (error: AxiosError) => {
|
|
263
|
+
const originalRequest = error.config as AxiosRequestConfig & { _retry?: boolean };
|
|
264
|
+
|
|
265
|
+
if (error.response?.status === 401 && !originalRequest._retry) {
|
|
266
|
+
if (isRefreshing) {
|
|
267
|
+
return new Promise((resolve, reject) => {
|
|
268
|
+
failedQueue.push({
|
|
269
|
+
resolve: (token) => {
|
|
270
|
+
originalRequest.headers = { ...originalRequest.headers, Authorization: `Bearer ${token}` };
|
|
271
|
+
resolve(api(originalRequest));
|
|
272
|
+
},
|
|
273
|
+
reject,
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
originalRequest._retry = true;
|
|
279
|
+
isRefreshing = true;
|
|
280
|
+
|
|
281
|
+
try {
|
|
282
|
+
const success = await useAuthStore.getState().refreshSession();
|
|
283
|
+
if (success) {
|
|
284
|
+
const newToken = useAuthStore.getState().token!;
|
|
285
|
+
processQueue(null, newToken);
|
|
286
|
+
originalRequest.headers = { ...originalRequest.headers, Authorization: `Bearer ${newToken}` };
|
|
287
|
+
return api(originalRequest);
|
|
288
|
+
}
|
|
289
|
+
} catch (refreshError) {
|
|
290
|
+
processQueue(refreshError as Error, null);
|
|
291
|
+
useAuthStore.getState().logout();
|
|
292
|
+
} finally {
|
|
293
|
+
isRefreshing = false;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return Promise.reject(normalizeError(error));
|
|
298
|
+
}
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
// Retry with exponential backoff
|
|
302
|
+
export async function apiWithRetry<T>(
|
|
303
|
+
fn: () => Promise<T>,
|
|
304
|
+
maxRetries = 3,
|
|
305
|
+
baseDelay = 1000
|
|
306
|
+
): Promise<T> {
|
|
307
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
308
|
+
try {
|
|
309
|
+
return await fn();
|
|
310
|
+
} catch (error) {
|
|
311
|
+
const isRetryable = error instanceof AppError && error.isRetryable;
|
|
312
|
+
if (!isRetryable || attempt === maxRetries) throw error;
|
|
313
|
+
const delay = baseDelay * Math.pow(2, attempt) + Math.random() * 500; // jitter
|
|
314
|
+
await new Promise(r => setTimeout(r, delay));
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
throw new Error('Unreachable');
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export default api;
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
### Flutter — Dio with Interceptors
|
|
324
|
+
|
|
325
|
+
```dart
|
|
326
|
+
// services/api_client.dart
|
|
327
|
+
import 'package:dio/dio.dart';
|
|
328
|
+
|
|
329
|
+
class ApiClient {
|
|
330
|
+
late final Dio _dio;
|
|
331
|
+
final TokenStorage _tokenStorage;
|
|
332
|
+
|
|
333
|
+
ApiClient({required TokenStorage tokenStorage}) : _tokenStorage = tokenStorage {
|
|
334
|
+
_dio = Dio(BaseOptions(
|
|
335
|
+
baseUrl: const String.fromEnvironment('API_URL'),
|
|
336
|
+
connectTimeout: const Duration(seconds: 15),
|
|
337
|
+
receiveTimeout: const Duration(seconds: 15),
|
|
338
|
+
));
|
|
339
|
+
|
|
340
|
+
_dio.interceptors.addAll([
|
|
341
|
+
_AuthInterceptor(_tokenStorage, _dio),
|
|
342
|
+
_RetryInterceptor(maxRetries: 3),
|
|
343
|
+
LogInterceptor(requestBody: true, responseBody: true),
|
|
344
|
+
]);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
Future<T> get<T>(String path, {Map<String, dynamic>? params, T Function(dynamic)? fromJson}) async {
|
|
348
|
+
final res = await _dio.get(path, queryParameters: params);
|
|
349
|
+
return fromJson != null ? fromJson(res.data) : res.data as T;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
Future<T> post<T>(String path, {dynamic data, T Function(dynamic)? fromJson}) async {
|
|
353
|
+
final res = await _dio.post(path, data: data);
|
|
354
|
+
return fromJson != null ? fromJson(res.data) : res.data as T;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
class _AuthInterceptor extends Interceptor {
|
|
359
|
+
final TokenStorage _storage;
|
|
360
|
+
final Dio _dio;
|
|
361
|
+
|
|
362
|
+
_AuthInterceptor(this._storage, this._dio);
|
|
363
|
+
|
|
364
|
+
@override
|
|
365
|
+
void onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
|
|
366
|
+
final token = await _storage.getAccessToken();
|
|
367
|
+
if (token != null) options.headers['Authorization'] = 'Bearer $token';
|
|
368
|
+
handler.next(options);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
@override
|
|
372
|
+
void onError(DioException err, ErrorInterceptorHandler handler) async {
|
|
373
|
+
if (err.response?.statusCode == 401) {
|
|
374
|
+
try {
|
|
375
|
+
final newToken = await _refreshToken();
|
|
376
|
+
err.requestOptions.headers['Authorization'] = 'Bearer $newToken';
|
|
377
|
+
final res = await _dio.fetch(err.requestOptions);
|
|
378
|
+
handler.resolve(res);
|
|
379
|
+
return;
|
|
380
|
+
} catch (_) {
|
|
381
|
+
await _storage.clear();
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
handler.next(err);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
Future<String> _refreshToken() async {
|
|
388
|
+
final refresh = await _storage.getRefreshToken();
|
|
389
|
+
final res = await _dio.post('/auth/refresh', data: {'refreshToken': refresh});
|
|
390
|
+
final token = res.data['accessToken'] as String;
|
|
391
|
+
await _storage.saveAccessToken(token);
|
|
392
|
+
return token;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
---
|
|
398
|
+
|
|
399
|
+
## Form Templates
|
|
400
|
+
|
|
401
|
+
### React Native — TanStack Form + Zod
|
|
402
|
+
|
|
403
|
+
```typescript
|
|
404
|
+
// forms/useLoginForm.ts
|
|
405
|
+
import { useForm } from '@tanstack/react-form';
|
|
406
|
+
import { zodValidator } from '@tanstack/zod-form-adapter';
|
|
407
|
+
import { z } from 'zod';
|
|
408
|
+
|
|
409
|
+
const loginSchema = z.object({
|
|
410
|
+
email: z.string().email('Invalid email address'),
|
|
411
|
+
password: z.string().min(8, 'Password must be at least 8 characters'),
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
export function useLoginForm(onSubmit: (data: z.infer<typeof loginSchema>) => Promise<void>) {
|
|
415
|
+
return useForm({
|
|
416
|
+
defaultValues: { email: '', password: '' },
|
|
417
|
+
validatorAdapter: zodValidator(),
|
|
418
|
+
validators: { onChange: loginSchema },
|
|
419
|
+
onSubmit: async ({ value }) => {
|
|
420
|
+
await onSubmit(value);
|
|
421
|
+
},
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// LoginScreen.tsx — usage
|
|
426
|
+
function LoginScreen() {
|
|
427
|
+
const form = useLoginForm(async (data) => {
|
|
428
|
+
await authService.login(data.email, data.password);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
return (
|
|
432
|
+
<form.Provider>
|
|
433
|
+
<form.Field name="email">
|
|
434
|
+
{(field) => (
|
|
435
|
+
<View>
|
|
436
|
+
<TextInput
|
|
437
|
+
value={field.state.value}
|
|
438
|
+
onChangeText={field.handleChange}
|
|
439
|
+
onBlur={field.handleBlur}
|
|
440
|
+
placeholder="Email"
|
|
441
|
+
keyboardType="email-address"
|
|
442
|
+
autoCapitalize="none"
|
|
443
|
+
/>
|
|
444
|
+
{field.state.meta.errors.length > 0 && (
|
|
445
|
+
<Text style={styles.error}>{field.state.meta.errors[0]}</Text>
|
|
446
|
+
)}
|
|
447
|
+
</View>
|
|
448
|
+
)}
|
|
449
|
+
</form.Field>
|
|
450
|
+
|
|
451
|
+
<form.Field name="password">
|
|
452
|
+
{(field) => (
|
|
453
|
+
<View>
|
|
454
|
+
<TextInput
|
|
455
|
+
value={field.state.value}
|
|
456
|
+
onChangeText={field.handleChange}
|
|
457
|
+
onBlur={field.handleBlur}
|
|
458
|
+
placeholder="Password"
|
|
459
|
+
secureTextEntry
|
|
460
|
+
/>
|
|
461
|
+
{field.state.meta.errors.length > 0 && (
|
|
462
|
+
<Text style={styles.error}>{field.state.meta.errors[0]}</Text>
|
|
463
|
+
)}
|
|
464
|
+
</View>
|
|
465
|
+
)}
|
|
466
|
+
</form.Field>
|
|
467
|
+
|
|
468
|
+
<form.Subscribe selector={(state) => [state.canSubmit, state.isSubmitting]}>
|
|
469
|
+
{([canSubmit, isSubmitting]) => (
|
|
470
|
+
<Button
|
|
471
|
+
title={isSubmitting ? 'Logging in...' : 'Login'}
|
|
472
|
+
onPress={form.handleSubmit}
|
|
473
|
+
disabled={!canSubmit || isSubmitting}
|
|
474
|
+
/>
|
|
475
|
+
)}
|
|
476
|
+
</form.Subscribe>
|
|
477
|
+
</form.Provider>
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
### Multi-Step Form Pattern
|
|
483
|
+
|
|
484
|
+
```typescript
|
|
485
|
+
// forms/useMultiStepForm.ts
|
|
486
|
+
import { useState, useCallback } from 'react';
|
|
487
|
+
|
|
488
|
+
interface StepConfig<T> {
|
|
489
|
+
validate: (data: Partial<T>) => Record<string, string> | null;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
export function useMultiStepForm<T extends Record<string, unknown>>(
|
|
493
|
+
steps: StepConfig<T>[],
|
|
494
|
+
initialData: Partial<T>
|
|
495
|
+
) {
|
|
496
|
+
const [currentStep, setCurrentStep] = useState(0);
|
|
497
|
+
const [data, setData] = useState<Partial<T>>(initialData);
|
|
498
|
+
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
499
|
+
|
|
500
|
+
const updateField = useCallback(<K extends keyof T>(key: K, value: T[K]) => {
|
|
501
|
+
setData(prev => ({ ...prev, [key]: value }));
|
|
502
|
+
setErrors(prev => { const next = { ...prev }; delete next[key as string]; return next; });
|
|
503
|
+
}, []);
|
|
504
|
+
|
|
505
|
+
const next = useCallback(() => {
|
|
506
|
+
const validationErrors = steps[currentStep].validate(data);
|
|
507
|
+
if (validationErrors) { setErrors(validationErrors); return false; }
|
|
508
|
+
setCurrentStep(prev => Math.min(prev + 1, steps.length - 1));
|
|
509
|
+
return true;
|
|
510
|
+
}, [currentStep, data, steps]);
|
|
511
|
+
|
|
512
|
+
const back = useCallback(() => {
|
|
513
|
+
setCurrentStep(prev => Math.max(prev - 1, 0));
|
|
514
|
+
}, []);
|
|
515
|
+
|
|
516
|
+
return {
|
|
517
|
+
currentStep,
|
|
518
|
+
totalSteps: steps.length,
|
|
519
|
+
data,
|
|
520
|
+
errors,
|
|
521
|
+
updateField,
|
|
522
|
+
next,
|
|
523
|
+
back,
|
|
524
|
+
isFirst: currentStep === 0,
|
|
525
|
+
isLast: currentStep === steps.length - 1,
|
|
526
|
+
progress: (currentStep + 1) / steps.length,
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
```
|
|
530
|
+
|
|
531
|
+
### File Upload with Progress
|
|
532
|
+
|
|
533
|
+
```typescript
|
|
534
|
+
// services/uploadService.ts
|
|
535
|
+
import * as ImagePicker from 'expo-image-picker';
|
|
536
|
+
import api from './api';
|
|
537
|
+
|
|
538
|
+
export async function pickAndUploadImage(
|
|
539
|
+
onProgress?: (percent: number) => void
|
|
540
|
+
): Promise<{ url: string }> {
|
|
541
|
+
// 1. Pick image
|
|
542
|
+
const result = await ImagePicker.launchImageLibraryAsync({
|
|
543
|
+
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
|
544
|
+
allowsEditing: true,
|
|
545
|
+
quality: 0.8,
|
|
546
|
+
base64: false,
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
if (result.canceled) throw new Error('User cancelled');
|
|
550
|
+
const asset = result.assets[0];
|
|
551
|
+
|
|
552
|
+
// 2. Validate
|
|
553
|
+
if (asset.fileSize && asset.fileSize > 5 * 1024 * 1024) {
|
|
554
|
+
throw new Error('Image must be under 5MB');
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// 3. Upload with progress
|
|
558
|
+
const formData = new FormData();
|
|
559
|
+
formData.append('file', {
|
|
560
|
+
uri: asset.uri,
|
|
561
|
+
type: asset.mimeType ?? 'image/jpeg',
|
|
562
|
+
name: asset.fileName ?? 'photo.jpg',
|
|
563
|
+
} as unknown as Blob);
|
|
564
|
+
|
|
565
|
+
const { data } = await api.post('/upload', formData, {
|
|
566
|
+
headers: { 'Content-Type': 'multipart/form-data' },
|
|
567
|
+
onUploadProgress: (e) => {
|
|
568
|
+
if (e.total) onProgress?.(Math.round((e.loaded / e.total) * 100));
|
|
569
|
+
},
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
return data;
|
|
573
|
+
}
|
|
574
|
+
```
|
|
575
|
+
|
|
576
|
+
---
|
|
577
|
+
|
|
578
|
+
## Type Generation Patterns
|
|
579
|
+
|
|
580
|
+
### API Response → TypeScript Interfaces
|
|
581
|
+
|
|
582
|
+
```typescript
|
|
583
|
+
// types/api.types.ts — Branded types for type safety
|
|
584
|
+
type Brand<T, B> = T & { __brand: B };
|
|
585
|
+
export type UserId = Brand<string, 'UserId'>;
|
|
586
|
+
export type ProductId = Brand<string, 'ProductId'>;
|
|
587
|
+
|
|
588
|
+
// API response interfaces — generated from response shape
|
|
589
|
+
export interface ApiResponse<T> {
|
|
590
|
+
data: T;
|
|
591
|
+
meta?: { page: number; totalPages: number; totalItems: number };
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
export interface ApiError {
|
|
595
|
+
code: string;
|
|
596
|
+
message: string;
|
|
597
|
+
details?: Record<string, string[]>;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// Domain entities
|
|
601
|
+
export interface User {
|
|
602
|
+
id: UserId;
|
|
603
|
+
email: string;
|
|
604
|
+
name: string;
|
|
605
|
+
avatarUrl: string | null;
|
|
606
|
+
role: 'user' | 'admin';
|
|
607
|
+
createdAt: string;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
export interface Product {
|
|
611
|
+
id: ProductId;
|
|
612
|
+
title: string;
|
|
613
|
+
description: string;
|
|
614
|
+
price: number;
|
|
615
|
+
images: string[];
|
|
616
|
+
category: string;
|
|
617
|
+
inStock: boolean;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// Discriminated union for async states
|
|
621
|
+
export type AsyncState<T> =
|
|
622
|
+
| { status: 'idle' }
|
|
623
|
+
| { status: 'loading' }
|
|
624
|
+
| { status: 'success'; data: T }
|
|
625
|
+
| { status: 'error'; error: string };
|
|
626
|
+
|
|
627
|
+
// Type guard
|
|
628
|
+
export function isSuccess<T>(state: AsyncState<T>): state is { status: 'success'; data: T } {
|
|
629
|
+
return state.status === 'success';
|
|
630
|
+
}
|
|
631
|
+
```
|
|
632
|
+
|
|
633
|
+
### Navigation Params Typing
|
|
634
|
+
|
|
635
|
+
```typescript
|
|
636
|
+
// navigation/types.ts
|
|
637
|
+
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
|
638
|
+
import { ProductId, UserId } from '@/types/api.types';
|
|
639
|
+
|
|
640
|
+
export type RootStackParamList = {
|
|
641
|
+
Home: undefined;
|
|
642
|
+
ProductDetail: { productId: ProductId };
|
|
643
|
+
Profile: { userId: UserId };
|
|
644
|
+
EditProfile: undefined;
|
|
645
|
+
Settings: undefined;
|
|
646
|
+
};
|
|
647
|
+
|
|
648
|
+
// Screen props — use in each screen
|
|
649
|
+
export type ProductDetailProps = NativeStackScreenProps<RootStackParamList, 'ProductDetail'>;
|
|
650
|
+
export type ProfileProps = NativeStackScreenProps<RootStackParamList, 'Profile'>;
|
|
651
|
+
|
|
652
|
+
// Type-safe navigation hook
|
|
653
|
+
import { useNavigation } from '@react-navigation/native';
|
|
654
|
+
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
|
655
|
+
export const useAppNavigation = () => useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
|
656
|
+
```
|
package/shared/code-review.md
CHANGED
|
@@ -50,14 +50,14 @@ CROSS-PLATFORM patterns (API contract, token expiry, navigation params):
|
|
|
50
50
|
|
|
51
51
|
USER SAYS → MODE → SCOPE
|
|
52
52
|
──────────────────────────────────────────────────────────────────────────────
|
|
53
|
-
"Review full code
|
|
54
|
-
"Review
|
|
55
|
-
"Review file X / review file
|
|
56
|
-
"Review function X / review
|
|
53
|
+
"Review full code" → FULL → Read ALL src/ files → 12-category checklist
|
|
54
|
+
"Review changes / review diff" → CHANGES → git diff (unstaged + staged) → review only changed code
|
|
55
|
+
"Review file X / review this file" → FILE → Read specific file(s) → 12-category checklist on those files only
|
|
56
|
+
"Review function X / review this function" → FUNCTION → Find function → trace callers/callees → deep review that function
|
|
57
57
|
"Review PR / review pull request" → PR → git diff [base]..HEAD → Step 0 PR-Level + 12-category on diff
|
|
58
|
-
"Review
|
|
59
|
-
"Review
|
|
60
|
-
"Check PR
|
|
58
|
+
"Review modified files" → MODIFIED → git status (modified only) → read + review modified files
|
|
59
|
+
"Review commits" → COMMITS → git log → git show [commit] → review each commit's changes
|
|
60
|
+
"Check PR" → PR-CHECK → Step 0 ONLY (size, scope, tests, commits) → quick pass/fail
|
|
61
61
|
|
|
62
62
|
═══ MODE DETAILS ═══
|
|
63
63
|
|
|
@@ -124,7 +124,7 @@ MODE: PR-CHECK (quick — no code review)
|
|
|
124
124
|
⛔ DEFAULT: If user says just "review" without specifying:
|
|
125
125
|
→ Check git status first
|
|
126
126
|
→ If changes exist → MODE: CHANGES (review what changed)
|
|
127
|
-
→ If no changes → ASK: "Review full codebase
|
|
127
|
+
→ If no changes → ASK: "Review full codebase or a specific file?"
|
|
128
128
|
```
|
|
129
129
|
|
|
130
130
|
---
|