@jeffrey2423/coding-standards 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.
@@ -0,0 +1,1288 @@
1
+ # Mobile React Native Development Standards
2
+
3
+ > **Note**: For shared architectural principles (Clean Architecture, DDD, design philosophy), refer to [architecture-patterns.md](./architecture-patterns.md). For design system (colors, typography, UX), refer to [technical-preferences-ux.md](./technical-preferences-ux.md). React Native shares the same core architecture philosophy as the frontend standards in [frontend-standards.md](./frontend-standards.md) — adapted for native mobile.
4
+
5
+ ---
6
+
7
+ ## Technology Stack
8
+
9
+ ### Core Technologies
10
+
11
+ | Category | Technology | Version | Notes |
12
+ |----------|------------|---------|-------|
13
+ | **Framework** | React Native | 0.77+ | New Architecture default (Fabric + TurboModules) |
14
+ | **Platform** | Expo | SDK 53+ | Managed workflow with EAS Build |
15
+ | **Language** | TypeScript | 5+ | Strict mode, no `any` |
16
+ | **Router** | Expo Router | 4+ | File-based, type-safe, deep-link ready |
17
+ | **State (client)** | Zustand | 5+ | Same as frontend standards |
18
+ | **State (server)** | TanStack Query | 5+ | Same as frontend standards |
19
+ | **Styling** | NativeWind | 4+ | TailwindCSS utility classes for RN |
20
+ | **Components** | `@rn-primitives` | Latest | Headless accessible RN primitives |
21
+ | **HTTP** | Axios | Latest | With interceptors |
22
+ | **Validation** | Zod | 3+ | Same as frontend standards |
23
+ | **Forms** | React Hook Form | 7+ | Same as frontend standards |
24
+ | **Storage** | MMKV | 3+ | Sync key-value, 10x faster than AsyncStorage |
25
+ | **Secure Storage** | `expo-secure-store` | Latest | Keychain / Keystore backed |
26
+ | **Testing** | Jest + RNTL | Latest | Unit, component, integration |
27
+
28
+ ### Development Tools
29
+
30
+ | Tool | Purpose |
31
+ |------|---------|
32
+ | Expo CLI | Project management and dev server |
33
+ | EAS CLI | Cloud builds and OTA updates |
34
+ | Expo DevTools | Inspector and debugger |
35
+ | Flipper (optional) | Native debugging |
36
+ | ESLint + Prettier | Same config as frontend |
37
+ | `expo-doctor` | Dependency health checks |
38
+
39
+ ---
40
+
41
+ ## Architecture
42
+
43
+ ### Style: Clean Architecture + DDD + Feature-First
44
+
45
+ Identical philosophy to the frontend and backend standards. Business logic drives every decision. Dependencies always point inward.
46
+
47
+ ```
48
+ ┌──────────────────────────────────────────────────────┐
49
+ │ PRESENTATION LAYER │
50
+ │ Screens, native components, Expo Router │
51
+ └──────────────────────────────────────────────────────┘
52
+
53
+
54
+ ┌──────────────────────────────────────────────────────┐
55
+ │ APPLICATION LAYER │
56
+ │ Use cases, Zustand stores, TanStack Query hooks │
57
+ └──────────────────────────────────────────────────────┘
58
+
59
+
60
+ ┌──────────────────────────────────────────────────────┐
61
+ │ INFRASTRUCTURE LAYER │
62
+ │ Repository implementations, APIs, MMKV │
63
+ └──────────────────────────────────────────────────────┘
64
+
65
+
66
+ ┌──────────────────────────────────────────────────────┐
67
+ │ DOMAIN LAYER │
68
+ │ Entities, value objects, repository interfaces │
69
+ └──────────────────────────────────────────────────────┘
70
+ ```
71
+
72
+ ### Folder Structure
73
+
74
+ ```
75
+ ├── app/ # Expo Router file-based routing
76
+ │ ├── _layout.tsx # Root layout (providers)
77
+ │ ├── index.tsx # Entry redirect
78
+ │ ├── (auth)/ # Public route group
79
+ │ │ ├── _layout.tsx
80
+ │ │ └── login.tsx # /login
81
+ │ └── (app)/ # Protected route group
82
+ │ ├── _layout.tsx # Tab / stack navigator
83
+ │ ├── dashboard.tsx
84
+ │ ├── sales/
85
+ │ │ ├── quotes/
86
+ │ │ │ ├── index.tsx # /sales/quotes
87
+ │ │ │ └── cart.tsx # /sales/quotes/cart
88
+ │ │ └── invoices.tsx
89
+ │ └── inventory/
90
+ │ └── products.tsx
91
+
92
+ ├── modules/ # Business modules (DDD)
93
+ │ ├── sales/
94
+ │ │ ├── quotes/
95
+ │ │ │ ├── cart/
96
+ │ │ │ │ ├── domain/
97
+ │ │ │ │ │ ├── entities/
98
+ │ │ │ │ │ │ └── cart-item.ts
99
+ │ │ │ │ │ ├── repositories/
100
+ │ │ │ │ │ │ └── i-cart-repository.ts
101
+ │ │ │ │ │ ├── value-objects/
102
+ │ │ │ │ │ │ └── quantity.ts
103
+ │ │ │ │ │ └── types/
104
+ │ │ │ │ │ └── cart.types.ts
105
+ │ │ │ │ ├── application/
106
+ │ │ │ │ │ ├── use-cases/
107
+ │ │ │ │ │ │ ├── add-to-cart.ts
108
+ │ │ │ │ │ │ └── remove-from-cart.ts
109
+ │ │ │ │ │ ├── hooks/
110
+ │ │ │ │ │ │ ├── use-cart.ts
111
+ │ │ │ │ │ │ └── use-cart-mutations.ts
112
+ │ │ │ │ │ └── store/
113
+ │ │ │ │ │ └── cart.store.ts
114
+ │ │ │ │ ├── infrastructure/
115
+ │ │ │ │ │ ├── repositories/
116
+ │ │ │ │ │ │ └── cart-repository.ts
117
+ │ │ │ │ │ ├── api/
118
+ │ │ │ │ │ │ └── cart.api.ts
119
+ │ │ │ │ │ └── storage/
120
+ │ │ │ │ │ └── cart-storage.ts
121
+ │ │ │ │ └── presentation/
122
+ │ │ │ │ ├── screens/
123
+ │ │ │ │ │ └── cart-screen.tsx
124
+ │ │ │ │ └── components/
125
+ │ │ │ │ ├── cart-list.tsx
126
+ │ │ │ │ └── cart-item.tsx
127
+ │ │ │ └── products/
128
+ │ │ └── billing/
129
+ │ ├── inventory/
130
+ │ └── users/
131
+
132
+ ├── shared/
133
+ │ ├── components/ # Reusable native components
134
+ │ │ ├── ui/ # Primitive wrappers
135
+ │ │ │ ├── button.tsx
136
+ │ │ │ ├── text.tsx
137
+ │ │ │ └── text-input.tsx
138
+ │ │ ├── feedback/
139
+ │ │ │ ├── skeleton.tsx
140
+ │ │ │ └── error-view.tsx
141
+ │ │ └── layout/
142
+ │ │ ├── screen.tsx # Safe-area aware screen wrapper
143
+ │ │ └── stack.tsx
144
+ │ ├── hooks/
145
+ │ │ ├── use-debounce.ts
146
+ │ │ └── use-app-state.ts
147
+ │ ├── lib/
148
+ │ │ ├── api-client.ts # Axios instance
149
+ │ │ ├── storage.ts # MMKV wrapper
150
+ │ │ └── env.ts # Typed env variables
151
+ │ ├── theme/
152
+ │ │ ├── colors.ts
153
+ │ │ ├── typography.ts
154
+ │ │ └── spacing.ts
155
+ │ ├── types/
156
+ │ │ └── common.types.ts
157
+ │ └── constants/
158
+ │ └── messages.ts # Spanish UI strings
159
+
160
+ ├── infrastructure/
161
+ │ ├── api/
162
+ │ │ └── http-client.ts
163
+ │ └── storage/
164
+ │ ├── mmkv-client.ts
165
+ │ └── secure-storage.ts
166
+
167
+ ├── assets/
168
+ │ ├── fonts/
169
+ │ │ ├── Inter_18pt-Light.ttf
170
+ │ │ ├── Inter_18pt-Regular.ttf
171
+ │ │ └── Inter_18pt-Bold.ttf
172
+ │ └── images/
173
+ │ └── logos/
174
+
175
+ ├── __tests__/
176
+ │ └── modules/
177
+ │ └── sales/
178
+ ├── jest.config.ts
179
+ ├── app.config.ts # Expo dynamic config
180
+ ├── tailwind.config.ts # NativeWind config
181
+ └── tsconfig.json # Strict mode
182
+ ```
183
+
184
+ ### Expo Router Conventions
185
+
186
+ Expo Router follows the same prefix system as TanStack Router (used in the frontend standards):
187
+
188
+ | Prefix | Effect | Example |
189
+ |--------|--------|---------|
190
+ | `(group)` | Route group — no URL segment, shared layout | `(auth)/_layout.tsx` |
191
+ | `[param]` | Dynamic segment | `[orderId].tsx` → `/:orderId` |
192
+ | `[...rest]` | Catch-all | `[...unmatched].tsx` |
193
+ | `+not-found.tsx` | 404 screen | App-level not found |
194
+ | `_layout.tsx` | Nested layout for a directory | Stack, Tabs, Drawer |
195
+
196
+ ---
197
+
198
+ ## Domain Layer
199
+
200
+ ### Entities
201
+
202
+ Entities are plain TypeScript objects. Use `readonly` everywhere.
203
+
204
+ ```typescript
205
+ // modules/sales/quotes/cart/domain/entities/cart-item.ts
206
+ export interface CartItem {
207
+ readonly id: string;
208
+ readonly productId: string;
209
+ readonly productName: string;
210
+ readonly quantity: number;
211
+ readonly unitPrice: number;
212
+ }
213
+
214
+ // Domain logic as pure functions — no classes required
215
+ export const getCartItemTotal = (item: CartItem): number =>
216
+ item.quantity * item.unitPrice;
217
+
218
+ export const isCartItemValid = (item: CartItem): boolean =>
219
+ item.quantity > 0 && item.unitPrice > 0;
220
+ ```
221
+
222
+ ### Value Objects
223
+
224
+ Value objects validate invariants at construction time. Use a `Result` discriminated union — never throw.
225
+
226
+ ```typescript
227
+ // shared/types/result.types.ts
228
+ export type Result<T, E = string> =
229
+ | { success: true; data: T }
230
+ | { success: false; error: E };
231
+
232
+ export const ok = <T>(data: T): Result<T> => ({ success: true, data });
233
+ export const err = <E>(error: E): Result<never, E> => ({ success: false, error });
234
+ ```
235
+
236
+ ```typescript
237
+ // modules/sales/quotes/cart/domain/value-objects/quantity.ts
238
+ import { Result, ok, err } from '@/shared/types/result.types';
239
+
240
+ export type QuantityError = 'INVALID_QUANTITY';
241
+
242
+ export interface Quantity {
243
+ readonly value: number;
244
+ }
245
+
246
+ export const createQuantity = (input: number): Result<Quantity, QuantityError> => {
247
+ if (!Number.isInteger(input) || input < 1) {
248
+ return err('INVALID_QUANTITY');
249
+ }
250
+ return ok({ value: input });
251
+ };
252
+ ```
253
+
254
+ ### Repository Interfaces
255
+
256
+ ```typescript
257
+ // modules/sales/quotes/cart/domain/repositories/i-cart-repository.ts
258
+ import type { CartItem } from '../entities/cart-item';
259
+ import type { Result } from '@/shared/types/result.types';
260
+
261
+ export type CartFailure =
262
+ | { type: 'NETWORK_ERROR'; message: string }
263
+ | { type: 'NOT_FOUND'; id: string }
264
+ | { type: 'INVALID_QUANTITY' }
265
+ | { type: 'CART_LIMIT_EXCEEDED'; maxItems: number };
266
+
267
+ export interface ICartRepository {
268
+ getCartItems(): Promise<Result<CartItem[], CartFailure>>;
269
+ addItem(productId: string, quantity: number): Promise<Result<CartItem, CartFailure>>;
270
+ removeItem(itemId: string): Promise<Result<void, CartFailure>>;
271
+ clearCart(): Promise<Result<void, CartFailure>>;
272
+ }
273
+ ```
274
+
275
+ ### Zod Schemas (Shared Domain + Infrastructure)
276
+
277
+ Define Zod schemas alongside the domain types. They serve as both validation and the single source of truth for external data shapes.
278
+
279
+ ```typescript
280
+ // modules/sales/quotes/cart/domain/types/cart.types.ts
281
+ import { z } from 'zod';
282
+
283
+ export const CartItemSchema = z.object({
284
+ id: z.string().uuid(),
285
+ productId: z.string().uuid(),
286
+ productName: z.string().min(1),
287
+ quantity: z.number().int().positive(),
288
+ unitPrice: z.number().positive(),
289
+ });
290
+
291
+ export type CartItemDto = z.infer<typeof CartItemSchema>;
292
+
293
+ export const CartResponseSchema = z.object({
294
+ items: z.array(CartItemSchema),
295
+ updatedAt: z.string().datetime(),
296
+ });
297
+ ```
298
+
299
+ ---
300
+
301
+ ## Application Layer
302
+
303
+ ### Use Cases
304
+
305
+ Each use case is a single function. One file per use case.
306
+
307
+ ```typescript
308
+ // modules/sales/quotes/cart/application/use-cases/add-to-cart.ts
309
+ import type { ICartRepository, CartFailure } from '../../domain/repositories/i-cart-repository';
310
+ import { createQuantity } from '../../domain/value-objects/quantity';
311
+ import type { CartItem } from '../../domain/entities/cart-item';
312
+ import { err, type Result } from '@/shared/types/result.types';
313
+
314
+ export const addToCartUseCase = (repository: ICartRepository) =>
315
+ async (
316
+ productId: string,
317
+ quantity: number,
318
+ ): Promise<Result<CartItem, CartFailure>> => {
319
+ const quantityResult = createQuantity(quantity);
320
+
321
+ if (!quantityResult.success) {
322
+ return err<CartFailure>({ type: 'INVALID_QUANTITY' });
323
+ }
324
+
325
+ return repository.addItem(productId, quantityResult.data.value);
326
+ };
327
+ ```
328
+
329
+ ### Zustand Store
330
+
331
+ Same pattern as frontend standards. Stores delegate to use cases.
332
+
333
+ ```typescript
334
+ // modules/sales/quotes/cart/application/store/cart.store.ts
335
+ import { create } from 'zustand';
336
+ import { devtools } from 'zustand/middleware';
337
+ import type { CartItem } from '../../domain/entities/cart-item';
338
+ import { addToCartUseCase } from '../use-cases/add-to-cart';
339
+ import { removeFromCartUseCase } from '../use-cases/remove-from-cart';
340
+ import { cartRepository } from '../../infrastructure/repositories/cart-repository';
341
+ import { mapCartFailureToMessage } from '../mappers/cart-failure.mapper';
342
+
343
+ const addToCart = addToCartUseCase(cartRepository);
344
+ const removeFromCart = removeFromCartUseCase(cartRepository);
345
+
346
+ interface CartState {
347
+ items: CartItem[];
348
+ loading: boolean;
349
+ error: string | null;
350
+ addItem: (productId: string, quantity: number) => Promise<void>;
351
+ removeItem: (itemId: string) => Promise<void>;
352
+ clearError: () => void;
353
+ }
354
+
355
+ export const useCartStore = create<CartState>()(
356
+ devtools(
357
+ (set) => ({
358
+ items: [],
359
+ loading: false,
360
+ error: null,
361
+
362
+ addItem: async (productId, quantity) => {
363
+ set({ loading: true, error: null });
364
+ const result = await addToCart(productId, quantity);
365
+ if (result.success) {
366
+ set((s) => ({ items: [...s.items, result.data], loading: false }));
367
+ } else {
368
+ set({ error: mapCartFailureToMessage(result.error), loading: false });
369
+ }
370
+ },
371
+
372
+ removeItem: async (itemId) => {
373
+ set({ loading: true, error: null });
374
+ const result = await removeFromCart(itemId);
375
+ if (result.success) {
376
+ set((s) => ({
377
+ items: s.items.filter((i) => i.id !== itemId),
378
+ loading: false,
379
+ }));
380
+ } else {
381
+ set({ error: mapCartFailureToMessage(result.error), loading: false });
382
+ }
383
+ },
384
+
385
+ clearError: () => set({ error: null }),
386
+ }),
387
+ { name: 'cartStore' },
388
+ ),
389
+ );
390
+ ```
391
+
392
+ ### TanStack Query Hooks
393
+
394
+ Use TanStack Query for server state, Zustand for client/UI state.
395
+
396
+ ```typescript
397
+ // modules/sales/quotes/cart/application/hooks/use-cart.ts
398
+ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
399
+ import { cartApi } from '../../infrastructure/api/cart.api';
400
+ import { CartItemSchema } from '../../domain/types/cart.types';
401
+ import { z } from 'zod';
402
+
403
+ export const cartKeys = {
404
+ all: ['cart'] as const,
405
+ items: () => [...cartKeys.all, 'items'] as const,
406
+ item: (id: string) => [...cartKeys.items(), id] as const,
407
+ };
408
+
409
+ export const useCartItems = () =>
410
+ useQuery({
411
+ queryKey: cartKeys.items(),
412
+ queryFn: async () => {
413
+ const data = await cartApi.getCartItems();
414
+ return z.array(CartItemSchema).parse(data);
415
+ },
416
+ staleTime: 30_000,
417
+ });
418
+
419
+ export const useAddToCart = () => {
420
+ const queryClient = useQueryClient();
421
+
422
+ return useMutation({
423
+ mutationFn: ({ productId, quantity }: { productId: string; quantity: number }) =>
424
+ cartApi.addItem(productId, quantity),
425
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: cartKeys.items() }),
426
+ });
427
+ };
428
+ ```
429
+
430
+ ---
431
+
432
+ ## Infrastructure Layer
433
+
434
+ ### HTTP Client
435
+
436
+ ```typescript
437
+ // infrastructure/api/http-client.ts
438
+ import axios, { AxiosError } from 'axios';
439
+ import { secureStorage } from '../storage/secure-storage';
440
+
441
+ export const httpClient = axios.create({
442
+ baseURL: process.env.EXPO_PUBLIC_API_BASE_URL,
443
+ timeout: 30_000,
444
+ headers: { 'Content-Type': 'application/json' },
445
+ });
446
+
447
+ httpClient.interceptors.request.use(async (config) => {
448
+ const token = await secureStorage.getToken();
449
+ if (token) config.headers.Authorization = `Bearer ${token}`;
450
+ return config;
451
+ });
452
+
453
+ httpClient.interceptors.response.use(
454
+ (r) => r,
455
+ async (error: AxiosError<{ message?: string }>) => {
456
+ if (error.response?.status === 401) {
457
+ await secureStorage.clearToken();
458
+ // Emit an event or use an auth store to navigate to login
459
+ }
460
+ const message = error.response?.data?.message ?? 'Error de conexión';
461
+ return Promise.reject(new Error(message));
462
+ },
463
+ );
464
+ ```
465
+
466
+ ### Repository Implementation
467
+
468
+ ```typescript
469
+ // modules/sales/quotes/cart/infrastructure/repositories/cart-repository.ts
470
+ import { cartApi } from '../api/cart.api';
471
+ import { cartStorage } from '../storage/cart-storage';
472
+ import type { ICartRepository, CartFailure } from '../../domain/repositories/i-cart-repository';
473
+ import type { CartItem } from '../../domain/entities/cart-item';
474
+ import { ok, err, type Result } from '@/shared/types/result.types';
475
+ import { CartItemSchema } from '../../domain/types/cart.types';
476
+ import { z } from 'zod';
477
+
478
+ export const cartRepository: ICartRepository = {
479
+ async getCartItems(): Promise<Result<CartItem[], CartFailure>> {
480
+ try {
481
+ const cached = cartStorage.getAll();
482
+ if (cached.length > 0) return ok(cached);
483
+
484
+ const data = await cartApi.getCartItems();
485
+ const items = z.array(CartItemSchema).parse(data);
486
+ cartStorage.saveAll(items);
487
+ return ok(items);
488
+ } catch (e) {
489
+ return err({ type: 'NETWORK_ERROR', message: (e as Error).message });
490
+ }
491
+ },
492
+
493
+ async addItem(productId, quantity): Promise<Result<CartItem, CartFailure>> {
494
+ try {
495
+ const data = await cartApi.addItem(productId, quantity);
496
+ const item = CartItemSchema.parse(data);
497
+ cartStorage.saveItem(item);
498
+ return ok(item);
499
+ } catch (e) {
500
+ return err({ type: 'NETWORK_ERROR', message: (e as Error).message });
501
+ }
502
+ },
503
+
504
+ async removeItem(itemId): Promise<Result<void, CartFailure>> {
505
+ try {
506
+ await cartApi.removeItem(itemId);
507
+ cartStorage.deleteItem(itemId);
508
+ return ok(undefined);
509
+ } catch (e) {
510
+ return err({ type: 'NETWORK_ERROR', message: (e as Error).message });
511
+ }
512
+ },
513
+
514
+ async clearCart(): Promise<Result<void, CartFailure>> {
515
+ try {
516
+ await cartApi.clearCart();
517
+ cartStorage.clearAll();
518
+ return ok(undefined);
519
+ } catch (e) {
520
+ return err({ type: 'NETWORK_ERROR', message: (e as Error).message });
521
+ }
522
+ },
523
+ };
524
+ ```
525
+
526
+ ### MMKV Storage Wrapper
527
+
528
+ ```typescript
529
+ // infrastructure/storage/mmkv-client.ts
530
+ import { MMKV } from 'react-native-mmkv';
531
+
532
+ export const storage = new MMKV({ id: 'app-storage' });
533
+
534
+ export const mmkvStorage = {
535
+ getString: (key: string): string | undefined => storage.getString(key),
536
+ setString: (key: string, value: string): void => storage.set(key, value),
537
+ delete: (key: string): void => storage.delete(key),
538
+ contains: (key: string): boolean => storage.contains(key),
539
+
540
+ getJson: <T>(key: string): T | null => {
541
+ const raw = storage.getString(key);
542
+ if (!raw) return null;
543
+ try { return JSON.parse(raw) as T; } catch { return null; }
544
+ },
545
+ setJson: <T>(key: string, value: T): void =>
546
+ storage.set(key, JSON.stringify(value)),
547
+ };
548
+ ```
549
+
550
+ ### Secure Storage
551
+
552
+ ```typescript
553
+ // infrastructure/storage/secure-storage.ts
554
+ import * as SecureStore from 'expo-secure-store';
555
+
556
+ const TOKEN_KEY = 'auth_token';
557
+
558
+ export const secureStorage = {
559
+ getToken: (): Promise<string | null> =>
560
+ SecureStore.getItemAsync(TOKEN_KEY),
561
+
562
+ setToken: (token: string): Promise<void> =>
563
+ SecureStore.setItemAsync(TOKEN_KEY, token, {
564
+ keychainAccessible: SecureStore.AFTER_FIRST_UNLOCK,
565
+ }),
566
+
567
+ clearToken: (): Promise<void> =>
568
+ SecureStore.deleteItemAsync(TOKEN_KEY),
569
+ };
570
+ ```
571
+
572
+ ---
573
+
574
+ ## Presentation Layer
575
+
576
+ ### Screens
577
+
578
+ Screens are thin. They connect stores/hooks to components and handle navigation.
579
+
580
+ ```typescript
581
+ // modules/sales/quotes/cart/presentation/screens/cart-screen.tsx
582
+ import React from 'react';
583
+ import { View, FlatList } from 'react-native';
584
+ import { useRouter } from 'expo-router';
585
+ import { useCartStore } from '../../application/store/cart.store';
586
+ import { CartItemComponent } from '../components/cart-item';
587
+ import { Screen } from '@/shared/components/layout/screen';
588
+ import { Button } from '@/shared/components/ui/button';
589
+ import { Text } from '@/shared/components/ui/text';
590
+ import { Skeleton } from '@/shared/components/feedback/skeleton';
591
+ import { MESSAGES } from '@/shared/constants/messages';
592
+
593
+ export function CartScreen() {
594
+ const router = useRouter();
595
+ const items = useCartStore((s) => s.items);
596
+ const loading = useCartStore((s) => s.loading);
597
+ const error = useCartStore((s) => s.error);
598
+ const removeItem = useCartStore((s) => s.removeItem);
599
+
600
+ if (loading) return <Skeleton rows={4} />;
601
+
602
+ if (error) {
603
+ return (
604
+ <Screen>
605
+ <Text className="text-red-500">{error}</Text>
606
+ </Screen>
607
+ );
608
+ }
609
+
610
+ return (
611
+ <Screen>
612
+ <FlatList
613
+ data={items}
614
+ keyExtractor={(item) => item.id}
615
+ renderItem={({ item }) => (
616
+ <CartItemComponent
617
+ item={item}
618
+ onRemove={() => removeItem(item.id)}
619
+ />
620
+ )}
621
+ ListEmptyComponent={
622
+ <Text className="text-slate-500 text-center mt-8">
623
+ {MESSAGES.CART.EMPTY}
624
+ </Text>
625
+ }
626
+ />
627
+ <Button
628
+ onPress={() => router.push('/sales/checkout')}
629
+ disabled={items.length === 0}
630
+ >
631
+ {MESSAGES.CART.CHECKOUT}
632
+ </Button>
633
+ </Screen>
634
+ );
635
+ }
636
+ ```
637
+
638
+ ### Shared UI Primitives
639
+
640
+ Wrap native primitives with your design system. Never use raw `Text` or `TouchableOpacity` outside of `shared/components/ui/`.
641
+
642
+ ```typescript
643
+ // shared/components/ui/text.tsx
644
+ import React from 'react';
645
+ import { Text as RNText, type TextProps } from 'react-native';
646
+
647
+ interface AppTextProps extends TextProps {
648
+ variant?: 'h1' | 'h2' | 'h3' | 'body' | 'bodySmall' | 'label' | 'caption';
649
+ }
650
+
651
+ const variantClassMap: Record<NonNullable<AppTextProps['variant']>, string> = {
652
+ h1: 'text-4xl font-bold text-slate-900 dark:text-slate-50',
653
+ h2: 'text-3xl font-bold text-slate-900 dark:text-slate-50',
654
+ h3: 'text-2xl font-semibold text-slate-900 dark:text-slate-50',
655
+ body: 'text-base font-normal text-slate-700 dark:text-slate-300',
656
+ bodySmall: 'text-sm font-normal text-slate-700 dark:text-slate-300',
657
+ label: 'text-sm font-medium text-slate-700 dark:text-slate-300',
658
+ caption: 'text-xs font-light text-slate-500 dark:text-slate-400',
659
+ };
660
+
661
+ export function Text({
662
+ variant = 'body',
663
+ className,
664
+ ...props
665
+ }: AppTextProps) {
666
+ return (
667
+ <RNText
668
+ className={`${variantClassMap[variant]} ${className ?? ''}`}
669
+ {...props}
670
+ />
671
+ );
672
+ }
673
+ ```
674
+
675
+ ```typescript
676
+ // shared/components/ui/button.tsx
677
+ import React from 'react';
678
+ import { Pressable, type PressableProps } from 'react-native';
679
+ import { Text } from './text';
680
+
681
+ interface ButtonProps extends PressableProps {
682
+ variant?: 'primary' | 'secondary' | 'ghost';
683
+ children: string;
684
+ }
685
+
686
+ const variantMap = {
687
+ primary: 'bg-primary-500 active:bg-primary-600 rounded-lg px-4 py-3',
688
+ secondary: 'bg-slate-200 dark:bg-slate-700 active:bg-slate-300 rounded-lg px-4 py-3',
689
+ ghost: 'active:bg-slate-100 dark:active:bg-slate-800 rounded-lg px-4 py-3',
690
+ };
691
+
692
+ const textVariantMap = {
693
+ primary: 'text-white font-semibold text-base',
694
+ secondary: 'text-slate-900 dark:text-slate-100 font-medium text-sm',
695
+ ghost: 'text-slate-700 dark:text-slate-300 font-normal text-base',
696
+ };
697
+
698
+ export function Button({ variant = 'primary', children, className, ...props }: ButtonProps) {
699
+ return (
700
+ <Pressable
701
+ className={`${variantMap[variant]} ${props.disabled ? 'opacity-50' : ''} ${className ?? ''}`}
702
+ accessibilityRole="button"
703
+ accessibilityLabel={children}
704
+ {...props}
705
+ >
706
+ <Text className={textVariantMap[variant]}>{children}</Text>
707
+ </Pressable>
708
+ );
709
+ }
710
+ ```
711
+
712
+ ### Safe-Area Screen Wrapper
713
+
714
+ ```typescript
715
+ // shared/components/layout/screen.tsx
716
+ import React from 'react';
717
+ import { type ViewProps } from 'react-native';
718
+ import { SafeAreaView } from 'react-native-safe-area-context';
719
+
720
+ interface ScreenProps extends ViewProps {
721
+ children: React.ReactNode;
722
+ }
723
+
724
+ export function Screen({ children, className, ...props }: ScreenProps) {
725
+ return (
726
+ <SafeAreaView
727
+ className={`flex-1 bg-white dark:bg-slate-950 px-4 ${className ?? ''}`}
728
+ edges={['top', 'bottom']}
729
+ {...props}
730
+ >
731
+ {children}
732
+ </SafeAreaView>
733
+ );
734
+ }
735
+ ```
736
+
737
+ ---
738
+
739
+ ## Navigation (Expo Router)
740
+
741
+ ### Root Layout — Providers
742
+
743
+ ```typescript
744
+ // app/_layout.tsx
745
+ import { useEffect } from 'react';
746
+ import { Stack } from 'expo-router';
747
+ import { useFonts } from 'expo-font';
748
+ import * as SplashScreen from 'expo-splash-screen';
749
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
750
+ import { ThemeProvider } from '@/shared/theme/theme-provider';
751
+ import { GestureHandlerRootView } from 'react-native-gesture-handler';
752
+ import { SafeAreaProvider } from 'react-native-safe-area-context';
753
+
754
+ SplashScreen.preventAutoHideAsync();
755
+
756
+ const queryClient = new QueryClient({
757
+ defaultOptions: {
758
+ queries: {
759
+ staleTime: 60_000,
760
+ retry: 2,
761
+ },
762
+ },
763
+ });
764
+
765
+ export default function RootLayout() {
766
+ const [loaded, error] = useFonts({
767
+ 'Inter-Light': require('@/assets/fonts/Inter_18pt-Light.ttf'),
768
+ 'Inter-Regular': require('@/assets/fonts/Inter_18pt-Regular.ttf'),
769
+ 'Inter-Bold': require('@/assets/fonts/Inter_18pt-Bold.ttf'),
770
+ });
771
+
772
+ useEffect(() => {
773
+ if (loaded || error) SplashScreen.hideAsync();
774
+ }, [loaded, error]);
775
+
776
+ if (!loaded && !error) return null;
777
+
778
+ return (
779
+ <GestureHandlerRootView style={{ flex: 1 }}>
780
+ <SafeAreaProvider>
781
+ <QueryClientProvider client={queryClient}>
782
+ <ThemeProvider>
783
+ <Stack screenOptions={{ headerShown: false }} />
784
+ </ThemeProvider>
785
+ </QueryClientProvider>
786
+ </SafeAreaProvider>
787
+ </GestureHandlerRootView>
788
+ );
789
+ }
790
+ ```
791
+
792
+ ### Auth Guard Layout
793
+
794
+ ```typescript
795
+ // app/(app)/_layout.tsx
796
+ import { Redirect, Tabs } from 'expo-router';
797
+ import { useAuthStore } from '@/modules/users/authentication/login/application/store/auth.store';
798
+
799
+ export default function AppLayout() {
800
+ const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
801
+
802
+ if (!isAuthenticated) return <Redirect href="/login" />;
803
+
804
+ return (
805
+ <Tabs screenOptions={{ headerShown: false }}>
806
+ <Tabs.Screen name="dashboard" options={{ title: 'Inicio' }} />
807
+ <Tabs.Screen name="sales" options={{ title: 'Ventas' }} />
808
+ <Tabs.Screen name="inventory" options={{ title: 'Inventario' }} />
809
+ </Tabs>
810
+ );
811
+ }
812
+ ```
813
+
814
+ ---
815
+
816
+ ## Design System
817
+
818
+ Mirrors the shared design tokens from `technical-preferences-ux.md`. NativeWind is configured once and consumed everywhere with className strings.
819
+
820
+ ### NativeWind + Tailwind Config
821
+
822
+ ```typescript
823
+ // tailwind.config.ts
824
+ import type { Config } from 'tailwindcss';
825
+
826
+ const config: Config = {
827
+ content: ['./app/**/*.{ts,tsx}', './modules/**/*.{ts,tsx}', './shared/**/*.{ts,tsx}'],
828
+ presets: [require('nativewind/preset')],
829
+ darkMode: 'class',
830
+ theme: {
831
+ extend: {
832
+ colors: {
833
+ primary: {
834
+ 50: '#eff8ff',
835
+ 100: '#dbf0ff',
836
+ 200: '#bfe3ff',
837
+ 300: '#93d2ff',
838
+ 400: '#60b6ff',
839
+ 500: '#0E79FD', // Main brand color
840
+ 600: '#0b6ae6',
841
+ 700: '#0959c2',
842
+ 800: '#0e4a9e',
843
+ 900: '#123f80',
844
+ 950: '#11274d',
845
+ },
846
+ secondary: {
847
+ 50: '#f8f8f8',
848
+ 100: '#f0f0f0',
849
+ 200: '#e4e4e4',
850
+ 300: '#d1d1d1',
851
+ 400: '#b4b4b4',
852
+ 500: '#9a9a9a',
853
+ 600: '#818181',
854
+ 700: '#6a6a6a',
855
+ 800: '#5a5a5a',
856
+ 900: '#4e4e4e',
857
+ 950: '#000000', // Main secondary color
858
+ },
859
+ tertiary: {
860
+ 700: '#154ca9', // Main tertiary color
861
+ },
862
+ },
863
+ fontFamily: {
864
+ 'inter-light': ['Inter-Light'],
865
+ 'inter': ['Inter-Regular'],
866
+ 'inter-bold': ['Inter-Bold'],
867
+ },
868
+ },
869
+ },
870
+ };
871
+
872
+ export default config;
873
+ ```
874
+
875
+ ### Color Constants (non-NativeWind contexts)
876
+
877
+ ```typescript
878
+ // shared/theme/colors.ts
879
+ export const Colors = {
880
+ primary500: '#0E79FD',
881
+ primary400: '#60B6FF',
882
+ tertiary700: '#154CA9',
883
+ secondary950: '#000000',
884
+
885
+ backgroundLight: '#FFFFFF',
886
+ surfaceLight: '#F8FAFC',
887
+ borderLight: '#E2E8F0',
888
+
889
+ backgroundDark: '#020617',
890
+ surfaceDark: '#0F172A',
891
+ borderDark: '#334155',
892
+
893
+ success: '#22C55E',
894
+ warning: '#F59E0B',
895
+ error: '#EF4444',
896
+ info: '#06B6D4',
897
+ } as const;
898
+ ```
899
+
900
+ ---
901
+
902
+ ## Testing Standards
903
+
904
+ ### Strategy
905
+
906
+ | Type | Tool | Coverage Target | What to Test |
907
+ |------|------|-----------------|--------------|
908
+ | **Unit** | Jest | > 80% | Entities, value objects, use cases, stores |
909
+ | **Component** | React Native Testing Library | Medium | Key UI interactions and accessibility |
910
+ | **Integration** | Jest + MSW | Medium | Full feature flows with mocked API |
911
+ | **E2E** | Maestro | Low | Critical user journeys on device |
912
+
913
+ ### Jest Config
914
+
915
+ ```typescript
916
+ // jest.config.ts
917
+ import type { Config } from 'jest';
918
+
919
+ const config: Config = {
920
+ preset: 'jest-expo',
921
+ setupFilesAfterFramework: ['<rootDir>/jest.setup.ts'],
922
+ moduleNameMapper: {
923
+ '^@/(.*)$': '<rootDir>/$1',
924
+ },
925
+ transformIgnorePatterns: [
926
+ 'node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|react-native-svg)',
927
+ ],
928
+ collectCoverageFrom: [
929
+ 'modules/**/*.{ts,tsx}',
930
+ 'shared/**/*.{ts,tsx}',
931
+ '!**/*.d.ts',
932
+ '!**/index.ts',
933
+ ],
934
+ };
935
+
936
+ export default config;
937
+ ```
938
+
939
+ ### Unit Test — Use Case
940
+
941
+ ```typescript
942
+ // __tests__/modules/sales/quotes/cart/application/use-cases/add-to-cart.test.ts
943
+ import { addToCartUseCase } from '@/modules/sales/quotes/cart/application/use-cases/add-to-cart';
944
+ import type { ICartRepository } from '@/modules/sales/quotes/cart/domain/repositories/i-cart-repository';
945
+ import type { CartItem } from '@/modules/sales/quotes/cart/domain/entities/cart-item';
946
+
947
+ const mockItem: CartItem = {
948
+ id: 'item-1',
949
+ productId: 'prod-1',
950
+ productName: 'Producto Test',
951
+ quantity: 2,
952
+ unitPrice: 50,
953
+ };
954
+
955
+ const mockRepository: ICartRepository = {
956
+ getCartItems: jest.fn(),
957
+ addItem: jest.fn().mockResolvedValue({ success: true, data: mockItem }),
958
+ removeItem: jest.fn(),
959
+ clearCart: jest.fn(),
960
+ };
961
+
962
+ describe('addToCartUseCase', () => {
963
+ const addToCart = addToCartUseCase(mockRepository);
964
+
965
+ it('returns the added item when quantity is valid', async () => {
966
+ const result = await addToCart('prod-1', 2);
967
+ expect(result.success).toBe(true);
968
+ if (result.success) expect(result.data).toEqual(mockItem);
969
+ expect(mockRepository.addItem).toHaveBeenCalledWith('prod-1', 2);
970
+ });
971
+
972
+ it('returns INVALID_QUANTITY failure when quantity is 0', async () => {
973
+ const result = await addToCart('prod-1', 0);
974
+ expect(result.success).toBe(false);
975
+ if (!result.success) expect(result.error.type).toBe('INVALID_QUANTITY');
976
+ expect(mockRepository.addItem).not.toHaveBeenCalled();
977
+ });
978
+
979
+ it('returns INVALID_QUANTITY failure for negative values', async () => {
980
+ const result = await addToCart('prod-1', -3);
981
+ expect(result.success).toBe(false);
982
+ });
983
+ });
984
+ ```
985
+
986
+ ### Component Test — React Native Testing Library
987
+
988
+ ```typescript
989
+ // __tests__/modules/sales/quotes/cart/presentation/components/cart-item.test.tsx
990
+ import React from 'react';
991
+ import { render, screen, fireEvent } from '@testing-library/react-native';
992
+ import { CartItemComponent } from '@/modules/sales/quotes/cart/presentation/components/cart-item';
993
+ import type { CartItem } from '@/modules/sales/quotes/cart/domain/entities/cart-item';
994
+
995
+ const mockItem: CartItem = {
996
+ id: '1',
997
+ productId: 'p1',
998
+ productName: 'Producto Test',
999
+ quantity: 2,
1000
+ unitPrice: 50,
1001
+ };
1002
+
1003
+ describe('CartItemComponent', () => {
1004
+ it('renders product name and total price', () => {
1005
+ render(<CartItemComponent item={mockItem} onRemove={jest.fn()} />);
1006
+ expect(screen.getByText('Producto Test')).toBeTruthy();
1007
+ expect(screen.getByText('$100.00')).toBeTruthy();
1008
+ });
1009
+
1010
+ it('calls onRemove with the item id when delete is pressed', () => {
1011
+ const onRemove = jest.fn();
1012
+ render(<CartItemComponent item={mockItem} onRemove={onRemove} />);
1013
+ fireEvent.press(screen.getByAccessibilityHint('Eliminar del carrito'));
1014
+ expect(onRemove).toHaveBeenCalledWith('1');
1015
+ });
1016
+ });
1017
+ ```
1018
+
1019
+ ---
1020
+
1021
+ ## Error Handling
1022
+
1023
+ ### Global Error Boundary
1024
+
1025
+ React Native does not support class `ErrorBoundary` for native crashes. Use `expo-error-reporter` and a React error boundary for JS errors.
1026
+
1027
+ ```typescript
1028
+ // shared/components/feedback/error-boundary.tsx
1029
+ import React, { Component, type ReactNode } from 'react';
1030
+ import { View } from 'react-native';
1031
+ import { Text } from '@/shared/components/ui/text';
1032
+ import { Button } from '@/shared/components/ui/button';
1033
+
1034
+ interface Props { children: ReactNode; }
1035
+ interface State { hasError: boolean; }
1036
+
1037
+ export class ErrorBoundary extends Component<Props, State> {
1038
+ state: State = { hasError: false };
1039
+
1040
+ static getDerivedStateFromError(): State {
1041
+ return { hasError: true };
1042
+ }
1043
+
1044
+ componentDidCatch(error: Error) {
1045
+ // Log to Sentry / Crashlytics
1046
+ console.error('[ErrorBoundary]', error);
1047
+ }
1048
+
1049
+ render() {
1050
+ if (this.state.hasError) {
1051
+ return (
1052
+ <View className="flex-1 items-center justify-center p-6 bg-white dark:bg-slate-950">
1053
+ <Text variant="h3" className="text-center mb-2">Algo salió mal</Text>
1054
+ <Text variant="body" className="text-center text-slate-500 mb-6">
1055
+ Por favor, intenta recargar la aplicación.
1056
+ </Text>
1057
+ <Button onPress={() => this.setState({ hasError: false })}>
1058
+ Reintentar
1059
+ </Button>
1060
+ </View>
1061
+ );
1062
+ }
1063
+ return this.props.children;
1064
+ }
1065
+ }
1066
+ ```
1067
+
1068
+ ### User-Visible Error Messages
1069
+
1070
+ Map domain failures to Spanish strings in a dedicated mapper. Never expose raw error messages to the UI.
1071
+
1072
+ ```typescript
1073
+ // modules/sales/quotes/cart/application/mappers/cart-failure.mapper.ts
1074
+ import type { CartFailure } from '../../domain/repositories/i-cart-repository';
1075
+ import { MESSAGES } from '@/shared/constants/messages';
1076
+
1077
+ export const mapCartFailureToMessage = (failure: CartFailure): string => {
1078
+ switch (failure.type) {
1079
+ case 'INVALID_QUANTITY':
1080
+ return 'La cantidad debe ser mayor a cero';
1081
+ case 'NOT_FOUND':
1082
+ return `El artículo ${failure.id} no fue encontrado`;
1083
+ case 'CART_LIMIT_EXCEEDED':
1084
+ return `El carrito no puede tener más de ${failure.maxItems} artículos`;
1085
+ case 'NETWORK_ERROR':
1086
+ return MESSAGES.ERROR.GENERIC;
1087
+ }
1088
+ };
1089
+ ```
1090
+
1091
+ ---
1092
+
1093
+ ## Security Standards
1094
+
1095
+ | Concern | Solution |
1096
+ |---------|---------|
1097
+ | Token storage | `expo-secure-store` (Keychain / AES Keystore) |
1098
+ | General storage | `react-native-mmkv` (encrypted instance) |
1099
+ | Certificate pinning | `react-native-ssl-pinning` or OkHttp config |
1100
+ | Screenshot prevention | `expo-screen-capture` — `preventScreenCaptureAsync()` |
1101
+ | Root / jailbreak detection | `jail-monkey` |
1102
+ | API keys | `EXPO_PUBLIC_*` only for non-sensitive; sensitive secrets stay server-side |
1103
+ | Obfuscation | ProGuard (Android) + Hermes bytecode |
1104
+
1105
+ ---
1106
+
1107
+ ## Performance Standards
1108
+
1109
+ | Strategy | Implementation |
1110
+ |----------|---------------|
1111
+ | List virtualization | Always `FlatList` or `FlashList` — never `ScrollView` with map |
1112
+ | Image caching | `expo-image` (built-in disk + memory cache) |
1113
+ | State granularity | Zustand selectors: `useStore((s) => s.specificField)` |
1114
+ | Re-render audit | React DevTools Profiler + `why-did-you-render` in dev |
1115
+ | Bundle size | `npx expo-optimize` + dynamic imports for heavy screens |
1116
+ | JS thread | Offload heavy work with `react-native-worklets-core` or `expo-task-manager` |
1117
+ | Startup | Defer non-critical logic after first frame with `InteractionManager.runAfterInteractions` |
1118
+ | Hermes | Always enabled (default in Expo SDK 50+) |
1119
+
1120
+ ---
1121
+
1122
+ ## Localization
1123
+
1124
+ All user-visible text must be in Spanish. Code, logs, comments, and git commits stay in English — identical to the frontend and backend standards.
1125
+
1126
+ ```typescript
1127
+ // shared/constants/messages.ts
1128
+ export const MESSAGES = {
1129
+ ERROR: {
1130
+ GENERIC: 'Ha ocurrido un error. Por favor, intenta de nuevo.',
1131
+ NOT_FOUND: 'El recurso solicitado no fue encontrado.',
1132
+ UNAUTHORIZED: 'No tienes permisos para realizar esta acción.',
1133
+ NETWORK: 'Sin conexión. Verifica tu red e intenta de nuevo.',
1134
+ },
1135
+ LOADING: {
1136
+ DEFAULT: 'Cargando...',
1137
+ SAVING: 'Guardando...',
1138
+ PROCESSING: 'Procesando...',
1139
+ },
1140
+ CART: {
1141
+ EMPTY: 'Tu carrito está vacío.',
1142
+ CHECKOUT: 'Finalizar compra',
1143
+ REMOVE: 'Eliminar del carrito',
1144
+ ADD: 'Agregar al carrito',
1145
+ },
1146
+ AUTH: {
1147
+ LOGIN: 'Iniciar sesión',
1148
+ LOGOUT: 'Cerrar sesión',
1149
+ },
1150
+ } as const;
1151
+ ```
1152
+
1153
+ For multi-language support, use `i18next` + `react-i18next` with `.json` resource files organized by locale.
1154
+
1155
+ ---
1156
+
1157
+ ## Expo Configuration
1158
+
1159
+ ```typescript
1160
+ // app.config.ts
1161
+ import type { ExpoConfig } from 'expo/config';
1162
+
1163
+ const config: ExpoConfig = {
1164
+ name: 'YourApp',
1165
+ slug: 'your-app',
1166
+ version: '1.0.0',
1167
+ orientation: 'portrait',
1168
+ icon: './assets/images/logos/icon.png',
1169
+ userInterfaceStyle: 'automatic', // Supports dark mode
1170
+ splash: {
1171
+ image: './assets/images/logos/splash.png',
1172
+ resizeMode: 'contain',
1173
+ backgroundColor: '#FFFFFF',
1174
+ },
1175
+ ios: {
1176
+ supportsTablet: false,
1177
+ bundleIdentifier: 'com.yourcompany.yourapp',
1178
+ infoPlist: {
1179
+ NSCameraUsageDescription: 'Se usa para escanear códigos de barras.',
1180
+ },
1181
+ },
1182
+ android: {
1183
+ package: 'com.yourcompany.yourapp',
1184
+ adaptiveIcon: {
1185
+ foregroundImage: './assets/images/logos/adaptive-icon.png',
1186
+ backgroundColor: '#0E79FD',
1187
+ },
1188
+ permissions: ['CAMERA'],
1189
+ },
1190
+ plugins: [
1191
+ 'expo-router',
1192
+ 'expo-secure-store',
1193
+ 'expo-font',
1194
+ ['expo-screen-orientation', { initialOrientation: 'PORTRAIT' }],
1195
+ ],
1196
+ experiments: {
1197
+ typedRoutes: true,
1198
+ },
1199
+ extra: {
1200
+ eas: { projectId: 'your-eas-project-id' },
1201
+ },
1202
+ };
1203
+
1204
+ export default config;
1205
+ ```
1206
+
1207
+ ---
1208
+
1209
+ ## package.json Reference
1210
+
1211
+ ```json
1212
+ {
1213
+ "dependencies": {
1214
+ "expo": "~53.0.0",
1215
+ "expo-router": "~4.0.0",
1216
+ "expo-font": "~12.0.0",
1217
+ "expo-splash-screen": "~0.27.0",
1218
+ "expo-secure-store": "~13.0.0",
1219
+ "expo-image": "~2.0.0",
1220
+ "expo-screen-capture": "~5.0.0",
1221
+ "react": "18.3.1",
1222
+ "react-native": "0.77.0",
1223
+ "react-native-safe-area-context": "4.12.0",
1224
+ "react-native-screens": "~4.4.0",
1225
+ "react-native-gesture-handler": "~2.20.0",
1226
+ "react-native-reanimated": "~3.16.0",
1227
+ "react-native-mmkv": "^3.1.0",
1228
+ "nativewind": "^4.1.0",
1229
+ "zustand": "^5.0.0",
1230
+ "@tanstack/react-query": "^5.62.0",
1231
+ "axios": "^1.7.0",
1232
+ "zod": "^3.23.0",
1233
+ "react-hook-form": "^7.53.0",
1234
+ "@hookform/resolvers": "^3.9.0"
1235
+ },
1236
+ "devDependencies": {
1237
+ "@types/react": "~18.3.0",
1238
+ "typescript": "~5.3.0",
1239
+ "eslint": "^8.57.0",
1240
+ "eslint-config-expo": "~8.0.0",
1241
+ "prettier": "^3.3.0",
1242
+ "jest": "^29.7.0",
1243
+ "jest-expo": "~53.0.0",
1244
+ "@testing-library/react-native": "^12.7.0",
1245
+ "tailwindcss": "^3.4.0"
1246
+ }
1247
+ }
1248
+ ```
1249
+
1250
+ ---
1251
+
1252
+ ## Implementation Checklist
1253
+
1254
+ ### Project Setup
1255
+ - [ ] `expo-doctor` passes with zero warnings
1256
+ - [ ] TypeScript strict mode enabled in `tsconfig.json`
1257
+ - [ ] NativeWind configured with `tailwind.config.ts` and brand colors matching `technical-preferences-ux.md`
1258
+ - [ ] Inter font assets loaded in `app/_layout.tsx`
1259
+ - [ ] `QueryClientProvider` and `SafeAreaProvider` in root layout
1260
+ - [ ] `ErrorBoundary` wrapping the navigator in `app/_layout.tsx`
1261
+ - [ ] `expo-secure-store` for token storage
1262
+ - [ ] MMKV for general storage
1263
+ - [ ] Auth guard in `app/(app)/_layout.tsx`
1264
+ - [ ] Typed Expo Router routes enabled (`experiments.typedRoutes: true`)
1265
+ - [ ] EAS project configured with `eas.json`
1266
+
1267
+ ### Per Feature
1268
+ - [ ] Domain entity with `readonly` fields
1269
+ - [ ] Value object returning `Result<T, E>` — never throws
1270
+ - [ ] Repository interface in domain layer
1271
+ - [ ] Use case(s) as pure functions, injected repository
1272
+ - [ ] Zustand store delegating to use cases
1273
+ - [ ] TanStack Query hooks for server state
1274
+ - [ ] Repository implementation with Zod parsing on all API responses
1275
+ - [ ] Domain failure mapper to Spanish strings
1276
+ - [ ] Screen using selectors (`useStore((s) => s.field)`)
1277
+ - [ ] Shared `Screen`, `Text`, `Button` primitives — no raw RN components in screens
1278
+ - [ ] Unit tests for use cases and value objects
1279
+ - [ ] Component tests for key user interactions
1280
+
1281
+ ### Quality
1282
+ - [ ] `eslint` and `prettier` return zero warnings
1283
+ - [ ] No `any` types anywhere
1284
+ - [ ] No raw `console.log` in production code
1285
+ - [ ] `FlatList` or `FlashList` for every dynamic list
1286
+ - [ ] `accessibilityRole` and `accessibilityLabel` on all interactive elements
1287
+ - [ ] All user-visible strings in `MESSAGES` constants (Spanish)
1288
+ - [ ] No API keys or secrets in client code