cad-workflow 1.1.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/LICENSE +21 -0
- package/README.md +88 -0
- package/bin/cli.js +529 -0
- package/bin/wrapper.js +32 -0
- package/config/install-config.yaml +167 -0
- package/package.json +42 -0
- package/src/base/.cad/config.yaml.tpl +25 -0
- package/src/base/.cad/workflow-status.yaml.tpl +18 -0
- package/src/base/.claude/settings.local.json.tpl +8 -0
- package/src/base/CLAUDE.md +69 -0
- package/src/base/commands/cad.md +547 -0
- package/src/base/commands/commit.md +103 -0
- package/src/base/commands/comprendre.md +96 -0
- package/src/base/commands/concevoir.md +121 -0
- package/src/base/commands/documenter.md +97 -0
- package/src/base/commands/e2e.md +79 -0
- package/src/base/commands/implementer.md +98 -0
- package/src/base/commands/review.md +85 -0
- package/src/base/commands/status.md +55 -0
- package/src/base/skills/clean-code/SKILL.md +92 -0
- package/src/base/skills/tdd/SKILL.md +132 -0
- package/src/integrations/jira/.mcp.json.tpl +19 -0
- package/src/integrations/jira/commands/jira-setup.md +34 -0
- package/src/stacks/backend-only/agents/backend-developer.md +167 -0
- package/src/stacks/backend-only/agents/backend-reviewer.md +89 -0
- package/src/stacks/backend-only/agents/orchestrator.md +69 -0
- package/src/stacks/backend-only/skills/clean-hexa-backend/SKILL.md +187 -0
- package/src/stacks/backend-only/skills/clean-hexa-backend/templates/adapter.template.ts +75 -0
- package/src/stacks/backend-only/skills/clean-hexa-backend/templates/controller.template.ts +131 -0
- package/src/stacks/backend-only/skills/clean-hexa-backend/templates/entity.template.ts +87 -0
- package/src/stacks/backend-only/skills/clean-hexa-backend/templates/port.template.ts +62 -0
- package/src/stacks/backend-only/skills/clean-hexa-backend/templates/use-case.template.ts +77 -0
- package/src/stacks/backend-only/skills/mutation-testing/SKILL.md +129 -0
- package/src/stacks/mobile/agents/backend-developer.md +167 -0
- package/src/stacks/mobile/agents/backend-reviewer.md +89 -0
- package/src/stacks/mobile/agents/mobile-developer.md +70 -0
- package/src/stacks/mobile/agents/mobile-reviewer.md +175 -0
- package/src/stacks/mobile/agents/orchestrator.md +69 -0
- package/src/stacks/mobile/skills/clean-hexa-backend/SKILL.md +187 -0
- package/src/stacks/mobile/skills/clean-hexa-backend/templates/adapter.template.ts +75 -0
- package/src/stacks/mobile/skills/clean-hexa-backend/templates/controller.template.ts +131 -0
- package/src/stacks/mobile/skills/clean-hexa-backend/templates/entity.template.ts +87 -0
- package/src/stacks/mobile/skills/clean-hexa-backend/templates/port.template.ts +62 -0
- package/src/stacks/mobile/skills/clean-hexa-backend/templates/use-case.template.ts +77 -0
- package/src/stacks/mobile/skills/clean-hexa-mobile/SKILL.md +984 -0
- package/src/stacks/mobile/skills/mutation-testing/SKILL.md +129 -0
- package/src/stacks/web/agents/backend-developer.md +167 -0
- package/src/stacks/web/agents/backend-reviewer.md +89 -0
- package/src/stacks/web/agents/frontend-developer.md +65 -0
- package/src/stacks/web/agents/frontend-reviewer.md +92 -0
- package/src/stacks/web/agents/orchestrator.md +69 -0
- package/src/stacks/web/skills/clean-hexa-backend/SKILL.md +187 -0
- package/src/stacks/web/skills/clean-hexa-backend/templates/adapter.template.ts +75 -0
- package/src/stacks/web/skills/clean-hexa-backend/templates/controller.template.ts +131 -0
- package/src/stacks/web/skills/clean-hexa-backend/templates/entity.template.ts +87 -0
- package/src/stacks/web/skills/clean-hexa-backend/templates/port.template.ts +62 -0
- package/src/stacks/web/skills/clean-hexa-backend/templates/use-case.template.ts +77 -0
- package/src/stacks/web/skills/clean-hexa-frontend/SKILL.md +172 -0
- package/src/stacks/web/skills/mutation-testing/SKILL.md +129 -0
|
@@ -0,0 +1,984 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: clean-hexa-mobile
|
|
3
|
+
description: Clean Architecture Hexagonale pour React Native Expo. Patterns, exemples et templates pour structurer une app mobile.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Clean Hexa Mobile - React Native Expo
|
|
7
|
+
|
|
8
|
+
## Principes fondamentaux
|
|
9
|
+
|
|
10
|
+
1. **Domain au centre** : Entités pures TypeScript, zéro dépendance React/Expo
|
|
11
|
+
2. **Use Cases = Custom Hooks** : Orchestration de la logique métier via hooks
|
|
12
|
+
3. **Injection via Context** : Pas de singletons, testabilité maximale
|
|
13
|
+
4. **Séparation Smart/Dumb** : Components présentationnels purs et réutilisables
|
|
14
|
+
5. **Ports & Adapters** : Interfaces dans le domain, implémentations dans infrastructure
|
|
15
|
+
|
|
16
|
+
## Structure de dossiers
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
src/
|
|
20
|
+
├── domain/ # Coeur métier - AUCUNE dépendance externe
|
|
21
|
+
│ ├── entities/ # Objets métier avec identité
|
|
22
|
+
│ │ └── todo.entity.ts
|
|
23
|
+
│ ├── value-objects/ # Objets immuables sans identité
|
|
24
|
+
│ │ └── todo-title.vo.ts
|
|
25
|
+
│ ├── ports/ # Interfaces (contrats)
|
|
26
|
+
│ │ └── todo.repository.port.ts
|
|
27
|
+
│ └── errors/ # Erreurs métier typées
|
|
28
|
+
│ └── todo-not-found.error.ts
|
|
29
|
+
├── application/ # Orchestration
|
|
30
|
+
│ ├── use-cases/ # Custom hooks métier
|
|
31
|
+
│ │ ├── get-all-todos.use-case.ts
|
|
32
|
+
│ │ ├── get-all-todos.use-case.test.ts
|
|
33
|
+
│ │ ├── create-todo.use-case.ts
|
|
34
|
+
│ │ └── create-todo.use-case.test.ts
|
|
35
|
+
│ ├── dtos/ # Data Transfer Objects
|
|
36
|
+
│ │ ├── todo.dto.ts
|
|
37
|
+
│ │ └── create-todo.dto.ts
|
|
38
|
+
│ └── mappers/ # Conversions Entity <-> DTO
|
|
39
|
+
│ └── todo.mapper.ts
|
|
40
|
+
├── infrastructure/ # Implémentations techniques
|
|
41
|
+
│ ├── adapters/ # Implémentations des ports
|
|
42
|
+
│ │ ├── api-todo.adapter.ts
|
|
43
|
+
│ │ └── api-todo.adapter.test.ts
|
|
44
|
+
│ ├── persistence/ # Stockage local
|
|
45
|
+
│ │ └── async-storage-todo.adapter.ts
|
|
46
|
+
│ ├── di/ # Dependency Injection
|
|
47
|
+
│ │ └── container.tsx
|
|
48
|
+
│ └── services/ # Services externes
|
|
49
|
+
│ └── api.client.ts
|
|
50
|
+
└── presentation/ # Interface utilisateur
|
|
51
|
+
├── components/
|
|
52
|
+
│ ├── smart/ # Connectés aux use cases
|
|
53
|
+
│ │ └── todo-list.container.tsx
|
|
54
|
+
│ └── dumb/ # Props only, purs
|
|
55
|
+
│ ├── todo-item.component.tsx
|
|
56
|
+
│ └── todo-item.component.test.tsx
|
|
57
|
+
└── hooks/ # Hooks UI (theme, navigation, etc.)
|
|
58
|
+
└── use-theme.ts
|
|
59
|
+
|
|
60
|
+
app/ # Expo Router - Routes uniquement
|
|
61
|
+
├── _layout.tsx # Root layout + providers
|
|
62
|
+
├── index.tsx # Home screen
|
|
63
|
+
├── (tabs)/
|
|
64
|
+
│ ├── _layout.tsx
|
|
65
|
+
│ └── todos.tsx
|
|
66
|
+
└── todo/
|
|
67
|
+
└── [id].tsx
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Règle des dépendances
|
|
71
|
+
|
|
72
|
+
Les imports vont TOUJOURS vers le centre :
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
presentation → application → domain ← infrastructure
|
|
76
|
+
↓ ↓ ↑
|
|
77
|
+
└──────────────┴───────────┘
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
- presentation importe application
|
|
81
|
+
- application importe domain
|
|
82
|
+
- infrastructure importe domain (pour implémenter les ports)
|
|
83
|
+
- presentation importe infrastructure (pour le DI container)
|
|
84
|
+
- domain n'importe JAMAIS rien d'externe
|
|
85
|
+
- application n'importe JAMAIS infrastructure
|
|
86
|
+
|
|
87
|
+
## Patterns détaillés
|
|
88
|
+
|
|
89
|
+
### 1. Entity (Domain)
|
|
90
|
+
|
|
91
|
+
```typescript
|
|
92
|
+
// domain/entities/todo.entity.ts
|
|
93
|
+
|
|
94
|
+
export interface TodoProps {
|
|
95
|
+
id: string;
|
|
96
|
+
title: string;
|
|
97
|
+
completed: boolean;
|
|
98
|
+
createdAt: Date;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export class Todo {
|
|
102
|
+
public readonly id: string;
|
|
103
|
+
public readonly title: string;
|
|
104
|
+
public readonly completed: boolean;
|
|
105
|
+
public readonly createdAt: Date;
|
|
106
|
+
|
|
107
|
+
private constructor(props: TodoProps) {
|
|
108
|
+
this.id = props.id;
|
|
109
|
+
this.title = props.title;
|
|
110
|
+
this.completed = props.completed;
|
|
111
|
+
this.createdAt = props.createdAt;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
static create(title: string): Todo {
|
|
115
|
+
if (!title || title.trim().length === 0) {
|
|
116
|
+
throw new Error('Todo title cannot be empty');
|
|
117
|
+
}
|
|
118
|
+
return new Todo({
|
|
119
|
+
id: crypto.randomUUID(),
|
|
120
|
+
title: title.trim(),
|
|
121
|
+
completed: false,
|
|
122
|
+
createdAt: new Date(),
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
static fromProps(props: TodoProps): Todo {
|
|
127
|
+
return new Todo(props);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
complete(): Todo {
|
|
131
|
+
return new Todo({ ...this, completed: true });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
uncomplete(): Todo {
|
|
135
|
+
return new Todo({ ...this, completed: false });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
updateTitle(newTitle: string): Todo {
|
|
139
|
+
if (!newTitle || newTitle.trim().length === 0) {
|
|
140
|
+
throw new Error('Todo title cannot be empty');
|
|
141
|
+
}
|
|
142
|
+
return new Todo({ ...this, title: newTitle.trim() });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### 2. Port (Domain)
|
|
148
|
+
|
|
149
|
+
```typescript
|
|
150
|
+
// domain/ports/todo.repository.port.ts
|
|
151
|
+
|
|
152
|
+
import { Todo } from '../entities/todo.entity';
|
|
153
|
+
|
|
154
|
+
export interface ITodoRepository {
|
|
155
|
+
findAll(): Promise<Todo[]>;
|
|
156
|
+
findById(id: string): Promise<Todo | null>;
|
|
157
|
+
save(todo: Todo): Promise<void>;
|
|
158
|
+
update(todo: Todo): Promise<void>;
|
|
159
|
+
delete(id: string): Promise<void>;
|
|
160
|
+
}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### 3. Domain Error
|
|
164
|
+
|
|
165
|
+
```typescript
|
|
166
|
+
// domain/errors/todo-not-found.error.ts
|
|
167
|
+
|
|
168
|
+
export class TodoNotFoundError extends Error {
|
|
169
|
+
constructor(id: string) {
|
|
170
|
+
super(`Todo with id "${id}" not found`);
|
|
171
|
+
this.name = 'TodoNotFoundError';
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
### 4. DTO (Application)
|
|
177
|
+
|
|
178
|
+
```typescript
|
|
179
|
+
// application/dtos/todo.dto.ts
|
|
180
|
+
|
|
181
|
+
export interface TodoDto {
|
|
182
|
+
id: string;
|
|
183
|
+
title: string;
|
|
184
|
+
completed: boolean;
|
|
185
|
+
createdAt: string;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export interface CreateTodoDto {
|
|
189
|
+
title: string;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export interface UpdateTodoDto {
|
|
193
|
+
title?: string;
|
|
194
|
+
completed?: boolean;
|
|
195
|
+
}
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
### 5. Mapper (Application)
|
|
199
|
+
|
|
200
|
+
```typescript
|
|
201
|
+
// application/mappers/todo.mapper.ts
|
|
202
|
+
|
|
203
|
+
import { Todo } from '@/domain/entities/todo.entity';
|
|
204
|
+
import { TodoDto } from '../dtos/todo.dto';
|
|
205
|
+
|
|
206
|
+
export class TodoMapper {
|
|
207
|
+
static toDto(entity: Todo): TodoDto {
|
|
208
|
+
return {
|
|
209
|
+
id: entity.id,
|
|
210
|
+
title: entity.title,
|
|
211
|
+
completed: entity.completed,
|
|
212
|
+
createdAt: entity.createdAt.toISOString(),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
static toDtoList(entities: Todo[]): TodoDto[] {
|
|
217
|
+
return entities.map(this.toDto);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
static toEntity(dto: TodoDto): Todo {
|
|
221
|
+
return Todo.fromProps({
|
|
222
|
+
id: dto.id,
|
|
223
|
+
title: dto.title,
|
|
224
|
+
completed: dto.completed,
|
|
225
|
+
createdAt: new Date(dto.createdAt),
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
### 6. Use Case Hook (Application)
|
|
232
|
+
|
|
233
|
+
```typescript
|
|
234
|
+
// application/use-cases/get-all-todos.use-case.ts
|
|
235
|
+
|
|
236
|
+
import { useState, useCallback } from 'react';
|
|
237
|
+
import { ITodoRepository } from '@/domain/ports/todo.repository.port';
|
|
238
|
+
import { TodoDto } from '../dtos/todo.dto';
|
|
239
|
+
import { TodoMapper } from '../mappers/todo.mapper';
|
|
240
|
+
|
|
241
|
+
interface UseGetAllTodosState {
|
|
242
|
+
todos: TodoDto[];
|
|
243
|
+
loading: boolean;
|
|
244
|
+
error: Error | null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
interface UseGetAllTodosReturn extends UseGetAllTodosState {
|
|
248
|
+
execute: () => Promise<void>;
|
|
249
|
+
reset: () => void;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export const useGetAllTodos = (repository: ITodoRepository): UseGetAllTodosReturn => {
|
|
253
|
+
const [state, setState] = useState<UseGetAllTodosState>({
|
|
254
|
+
todos: [],
|
|
255
|
+
loading: false,
|
|
256
|
+
error: null,
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
const execute = useCallback(async () => {
|
|
260
|
+
setState(prev => ({ ...prev, loading: true, error: null }));
|
|
261
|
+
try {
|
|
262
|
+
const entities = await repository.findAll();
|
|
263
|
+
const dtos = TodoMapper.toDtoList(entities);
|
|
264
|
+
setState({ todos: dtos, loading: false, error: null });
|
|
265
|
+
} catch (error) {
|
|
266
|
+
setState(prev => ({ ...prev, loading: false, error: error as Error }));
|
|
267
|
+
}
|
|
268
|
+
}, [repository]);
|
|
269
|
+
|
|
270
|
+
const reset = useCallback(() => {
|
|
271
|
+
setState({ todos: [], loading: false, error: null });
|
|
272
|
+
}, []);
|
|
273
|
+
|
|
274
|
+
return { ...state, execute, reset };
|
|
275
|
+
};
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
```typescript
|
|
279
|
+
// application/use-cases/create-todo.use-case.ts
|
|
280
|
+
|
|
281
|
+
import { useState, useCallback } from 'react';
|
|
282
|
+
import { ITodoRepository } from '@/domain/ports/todo.repository.port';
|
|
283
|
+
import { Todo } from '@/domain/entities/todo.entity';
|
|
284
|
+
import { CreateTodoDto, TodoDto } from '../dtos/todo.dto';
|
|
285
|
+
import { TodoMapper } from '../mappers/todo.mapper';
|
|
286
|
+
|
|
287
|
+
interface UseCreateTodoState {
|
|
288
|
+
createdTodo: TodoDto | null;
|
|
289
|
+
loading: boolean;
|
|
290
|
+
error: Error | null;
|
|
291
|
+
success: boolean;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
interface UseCreateTodoReturn extends UseCreateTodoState {
|
|
295
|
+
execute: (dto: CreateTodoDto) => Promise<void>;
|
|
296
|
+
reset: () => void;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export const useCreateTodo = (repository: ITodoRepository): UseCreateTodoReturn => {
|
|
300
|
+
const [state, setState] = useState<UseCreateTodoState>({
|
|
301
|
+
createdTodo: null,
|
|
302
|
+
loading: false,
|
|
303
|
+
error: null,
|
|
304
|
+
success: false,
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
const execute = useCallback(async (dto: CreateTodoDto) => {
|
|
308
|
+
setState(prev => ({ ...prev, loading: true, error: null, success: false }));
|
|
309
|
+
try {
|
|
310
|
+
const entity = Todo.create(dto.title);
|
|
311
|
+
await repository.save(entity);
|
|
312
|
+
const todoDto = TodoMapper.toDto(entity);
|
|
313
|
+
setState({ createdTodo: todoDto, loading: false, error: null, success: true });
|
|
314
|
+
} catch (error) {
|
|
315
|
+
setState(prev => ({ ...prev, loading: false, error: error as Error, success: false }));
|
|
316
|
+
}
|
|
317
|
+
}, [repository]);
|
|
318
|
+
|
|
319
|
+
const reset = useCallback(() => {
|
|
320
|
+
setState({ createdTodo: null, loading: false, error: null, success: false });
|
|
321
|
+
}, []);
|
|
322
|
+
|
|
323
|
+
return { ...state, execute, reset };
|
|
324
|
+
};
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
### 7. Use Case Test
|
|
328
|
+
|
|
329
|
+
```typescript
|
|
330
|
+
// application/use-cases/get-all-todos.use-case.test.ts
|
|
331
|
+
|
|
332
|
+
import { renderHook, act, waitFor } from '@testing-library/react-native';
|
|
333
|
+
import { useGetAllTodos } from './get-all-todos.use-case';
|
|
334
|
+
import { ITodoRepository } from '@/domain/ports/todo.repository.port';
|
|
335
|
+
import { Todo } from '@/domain/entities/todo.entity';
|
|
336
|
+
|
|
337
|
+
describe('useGetAllTodos', () => {
|
|
338
|
+
const createMockRepository = (): jest.Mocked<ITodoRepository> => ({
|
|
339
|
+
findAll: jest.fn(),
|
|
340
|
+
findById: jest.fn(),
|
|
341
|
+
save: jest.fn(),
|
|
342
|
+
update: jest.fn(),
|
|
343
|
+
delete: jest.fn(),
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('should return empty array initially', () => {
|
|
347
|
+
const mockRepo = createMockRepository();
|
|
348
|
+
const { result } = renderHook(() => useGetAllTodos(mockRepo));
|
|
349
|
+
|
|
350
|
+
expect(result.current.todos).toEqual([]);
|
|
351
|
+
expect(result.current.loading).toBe(false);
|
|
352
|
+
expect(result.current.error).toBeNull();
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it('should fetch todos successfully', async () => {
|
|
356
|
+
const mockRepo = createMockRepository();
|
|
357
|
+
const mockTodos = [
|
|
358
|
+
Todo.fromProps({ id: '1', title: 'Test 1', completed: false, createdAt: new Date() }),
|
|
359
|
+
Todo.fromProps({ id: '2', title: 'Test 2', completed: true, createdAt: new Date() }),
|
|
360
|
+
];
|
|
361
|
+
mockRepo.findAll.mockResolvedValue(mockTodos);
|
|
362
|
+
|
|
363
|
+
const { result } = renderHook(() => useGetAllTodos(mockRepo));
|
|
364
|
+
|
|
365
|
+
await act(async () => {
|
|
366
|
+
await result.current.execute();
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
await waitFor(() => {
|
|
370
|
+
expect(result.current.loading).toBe(false);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
expect(result.current.todos).toHaveLength(2);
|
|
374
|
+
expect(result.current.error).toBeNull();
|
|
375
|
+
expect(mockRepo.findAll).toHaveBeenCalledTimes(1);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it('should handle error', async () => {
|
|
379
|
+
const mockRepo = createMockRepository();
|
|
380
|
+
const error = new Error('Network error');
|
|
381
|
+
mockRepo.findAll.mockRejectedValue(error);
|
|
382
|
+
|
|
383
|
+
const { result } = renderHook(() => useGetAllTodos(mockRepo));
|
|
384
|
+
|
|
385
|
+
await act(async () => {
|
|
386
|
+
await result.current.execute();
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
await waitFor(() => {
|
|
390
|
+
expect(result.current.loading).toBe(false);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
expect(result.current.error).toBe(error);
|
|
394
|
+
expect(result.current.todos).toEqual([]);
|
|
395
|
+
});
|
|
396
|
+
});
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
### 8. Adapter (Infrastructure)
|
|
400
|
+
|
|
401
|
+
```typescript
|
|
402
|
+
// infrastructure/adapters/api-todo.adapter.ts
|
|
403
|
+
|
|
404
|
+
import { ITodoRepository } from '@/domain/ports/todo.repository.port';
|
|
405
|
+
import { Todo } from '@/domain/entities/todo.entity';
|
|
406
|
+
import { apiClient } from '../services/api.client';
|
|
407
|
+
|
|
408
|
+
interface ApiTodoResponse {
|
|
409
|
+
id: string;
|
|
410
|
+
title: string;
|
|
411
|
+
completed: boolean;
|
|
412
|
+
createdAt: string;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
export class ApiTodoAdapter implements ITodoRepository {
|
|
416
|
+
async findAll(): Promise<Todo[]> {
|
|
417
|
+
const response = await apiClient.get<ApiTodoResponse[]>('/todos');
|
|
418
|
+
return response.map(item => Todo.fromProps({
|
|
419
|
+
id: item.id,
|
|
420
|
+
title: item.title,
|
|
421
|
+
completed: item.completed,
|
|
422
|
+
createdAt: new Date(item.createdAt),
|
|
423
|
+
}));
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
async findById(id: string): Promise<Todo | null> {
|
|
427
|
+
try {
|
|
428
|
+
const response = await apiClient.get<ApiTodoResponse>(`/todos/${id}`);
|
|
429
|
+
return Todo.fromProps({
|
|
430
|
+
id: response.id,
|
|
431
|
+
title: response.title,
|
|
432
|
+
completed: response.completed,
|
|
433
|
+
createdAt: new Date(response.createdAt),
|
|
434
|
+
});
|
|
435
|
+
} catch {
|
|
436
|
+
return null;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
async save(todo: Todo): Promise<void> {
|
|
441
|
+
await apiClient.post('/todos', {
|
|
442
|
+
id: todo.id,
|
|
443
|
+
title: todo.title,
|
|
444
|
+
completed: todo.completed,
|
|
445
|
+
createdAt: todo.createdAt.toISOString(),
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
async update(todo: Todo): Promise<void> {
|
|
450
|
+
await apiClient.put(`/todos/${todo.id}`, {
|
|
451
|
+
title: todo.title,
|
|
452
|
+
completed: todo.completed,
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
async delete(id: string): Promise<void> {
|
|
457
|
+
await apiClient.delete(`/todos/${id}`);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
```typescript
|
|
463
|
+
// infrastructure/persistence/in-memory-todo.adapter.ts
|
|
464
|
+
|
|
465
|
+
import { ITodoRepository } from '@/domain/ports/todo.repository.port';
|
|
466
|
+
import { Todo } from '@/domain/entities/todo.entity';
|
|
467
|
+
|
|
468
|
+
export class InMemoryTodoAdapter implements ITodoRepository {
|
|
469
|
+
private todos: Map<string, Todo> = new Map();
|
|
470
|
+
|
|
471
|
+
async findAll(): Promise<Todo[]> {
|
|
472
|
+
return Array.from(this.todos.values());
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
async findById(id: string): Promise<Todo | null> {
|
|
476
|
+
return this.todos.get(id) ?? null;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
async save(todo: Todo): Promise<void> {
|
|
480
|
+
this.todos.set(todo.id, todo);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
async update(todo: Todo): Promise<void> {
|
|
484
|
+
if (!this.todos.has(todo.id)) {
|
|
485
|
+
throw new Error(`Todo ${todo.id} not found`);
|
|
486
|
+
}
|
|
487
|
+
this.todos.set(todo.id, todo);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
async delete(id: string): Promise<void> {
|
|
491
|
+
this.todos.delete(id);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// Helper pour les tests
|
|
495
|
+
clear(): void {
|
|
496
|
+
this.todos.clear();
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
seed(todos: Todo[]): void {
|
|
500
|
+
todos.forEach(todo => this.todos.set(todo.id, todo));
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
```
|
|
504
|
+
|
|
505
|
+
### 9. API Client (Infrastructure)
|
|
506
|
+
|
|
507
|
+
```typescript
|
|
508
|
+
// infrastructure/services/api.client.ts
|
|
509
|
+
|
|
510
|
+
const API_BASE_URL = process.env.EXPO_PUBLIC_API_URL || 'http://localhost:3000/api';
|
|
511
|
+
|
|
512
|
+
class ApiClient {
|
|
513
|
+
private baseUrl: string;
|
|
514
|
+
|
|
515
|
+
constructor(baseUrl: string) {
|
|
516
|
+
this.baseUrl = baseUrl;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
private async request<T>(endpoint: string, options?: RequestInit): Promise<T> {
|
|
520
|
+
const url = `${this.baseUrl}${endpoint}`;
|
|
521
|
+
const response = await fetch(url, {
|
|
522
|
+
...options,
|
|
523
|
+
headers: {
|
|
524
|
+
'Content-Type': 'application/json',
|
|
525
|
+
...options?.headers,
|
|
526
|
+
},
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
if (!response.ok) {
|
|
530
|
+
throw new Error(`API Error: ${response.status} ${response.statusText}`);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
return response.json();
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
async get<T>(endpoint: string): Promise<T> {
|
|
537
|
+
return this.request<T>(endpoint, { method: 'GET' });
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
async post<T>(endpoint: string, data: unknown): Promise<T> {
|
|
541
|
+
return this.request<T>(endpoint, {
|
|
542
|
+
method: 'POST',
|
|
543
|
+
body: JSON.stringify(data),
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
async put<T>(endpoint: string, data: unknown): Promise<T> {
|
|
548
|
+
return this.request<T>(endpoint, {
|
|
549
|
+
method: 'PUT',
|
|
550
|
+
body: JSON.stringify(data),
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
async delete<T>(endpoint: string): Promise<T> {
|
|
555
|
+
return this.request<T>(endpoint, { method: 'DELETE' });
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
export const apiClient = new ApiClient(API_BASE_URL);
|
|
560
|
+
```
|
|
561
|
+
|
|
562
|
+
### 10. Container DI (Infrastructure)
|
|
563
|
+
|
|
564
|
+
```typescript
|
|
565
|
+
// infrastructure/di/container.tsx
|
|
566
|
+
|
|
567
|
+
import React, { createContext, useContext, useMemo, ReactNode } from 'react';
|
|
568
|
+
import { ITodoRepository } from '@/domain/ports/todo.repository.port';
|
|
569
|
+
import { ApiTodoAdapter } from '../adapters/api-todo.adapter';
|
|
570
|
+
import { InMemoryTodoAdapter } from '../persistence/in-memory-todo.adapter';
|
|
571
|
+
|
|
572
|
+
interface Container {
|
|
573
|
+
todoRepository: ITodoRepository;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const ContainerContext = createContext<Container | null>(null);
|
|
577
|
+
|
|
578
|
+
interface ContainerProviderProps {
|
|
579
|
+
children: ReactNode;
|
|
580
|
+
overrides?: Partial<Container>;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
export const ContainerProvider: React.FC<ContainerProviderProps> = ({
|
|
584
|
+
children,
|
|
585
|
+
overrides
|
|
586
|
+
}) => {
|
|
587
|
+
const container = useMemo<Container>(() => ({
|
|
588
|
+
todoRepository: overrides?.todoRepository ?? new ApiTodoAdapter(),
|
|
589
|
+
}), [overrides]);
|
|
590
|
+
|
|
591
|
+
return (
|
|
592
|
+
<ContainerContext.Provider value={container}>
|
|
593
|
+
{children}
|
|
594
|
+
</ContainerContext.Provider>
|
|
595
|
+
);
|
|
596
|
+
};
|
|
597
|
+
|
|
598
|
+
export const useContainer = (): Container => {
|
|
599
|
+
const container = useContext(ContainerContext);
|
|
600
|
+
if (!container) {
|
|
601
|
+
throw new Error('useContainer must be used within a ContainerProvider');
|
|
602
|
+
}
|
|
603
|
+
return container;
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
// Hooks raccourcis pour chaque dépendance
|
|
607
|
+
export const useTodoRepository = (): ITodoRepository => {
|
|
608
|
+
return useContainer().todoRepository;
|
|
609
|
+
};
|
|
610
|
+
|
|
611
|
+
// Provider pour les tests avec mocks
|
|
612
|
+
export const TestContainerProvider: React.FC<ContainerProviderProps> = ({
|
|
613
|
+
children,
|
|
614
|
+
overrides
|
|
615
|
+
}) => {
|
|
616
|
+
const container = useMemo<Container>(() => ({
|
|
617
|
+
todoRepository: overrides?.todoRepository ?? new InMemoryTodoAdapter(),
|
|
618
|
+
}), [overrides]);
|
|
619
|
+
|
|
620
|
+
return (
|
|
621
|
+
<ContainerContext.Provider value={container}>
|
|
622
|
+
{children}
|
|
623
|
+
</ContainerContext.Provider>
|
|
624
|
+
);
|
|
625
|
+
};
|
|
626
|
+
```
|
|
627
|
+
|
|
628
|
+
### 11. Smart Component (Presentation)
|
|
629
|
+
|
|
630
|
+
```typescript
|
|
631
|
+
// presentation/components/smart/todo-list.container.tsx
|
|
632
|
+
|
|
633
|
+
import React, { useEffect, useCallback } from 'react';
|
|
634
|
+
import { View, StyleSheet, RefreshControl } from 'react-native';
|
|
635
|
+
import { useTodoRepository } from '@/infrastructure/di/container';
|
|
636
|
+
import { useGetAllTodos } from '@/application/use-cases/get-all-todos.use-case';
|
|
637
|
+
import { useDeleteTodo } from '@/application/use-cases/delete-todo.use-case';
|
|
638
|
+
import { useToggleTodo } from '@/application/use-cases/toggle-todo.use-case';
|
|
639
|
+
import { TodoList } from '../dumb/todo-list.component';
|
|
640
|
+
import { LoadingSpinner } from '../dumb/loading-spinner.component';
|
|
641
|
+
import { ErrorMessage } from '../dumb/error-message.component';
|
|
642
|
+
import { EmptyState } from '../dumb/empty-state.component';
|
|
643
|
+
|
|
644
|
+
export const TodoListContainer: React.FC = () => {
|
|
645
|
+
const repository = useTodoRepository();
|
|
646
|
+
const { todos, loading, error, execute: fetchTodos } = useGetAllTodos(repository);
|
|
647
|
+
const { execute: deleteTodo } = useDeleteTodo(repository);
|
|
648
|
+
const { execute: toggleTodo } = useToggleTodo(repository);
|
|
649
|
+
|
|
650
|
+
useEffect(() => {
|
|
651
|
+
fetchTodos();
|
|
652
|
+
}, [fetchTodos]);
|
|
653
|
+
|
|
654
|
+
const handleToggle = useCallback(async (id: string) => {
|
|
655
|
+
await toggleTodo(id);
|
|
656
|
+
await fetchTodos();
|
|
657
|
+
}, [toggleTodo, fetchTodos]);
|
|
658
|
+
|
|
659
|
+
const handleDelete = useCallback(async (id: string) => {
|
|
660
|
+
await deleteTodo(id);
|
|
661
|
+
await fetchTodos();
|
|
662
|
+
}, [deleteTodo, fetchTodos]);
|
|
663
|
+
|
|
664
|
+
if (loading && todos.length === 0) {
|
|
665
|
+
return <LoadingSpinner />;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
if (error) {
|
|
669
|
+
return <ErrorMessage message={error.message} onRetry={fetchTodos} />;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
if (todos.length === 0) {
|
|
673
|
+
return <EmptyState message="No todos yet. Create one!" />;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
return (
|
|
677
|
+
<View style={styles.container}>
|
|
678
|
+
<TodoList
|
|
679
|
+
todos={todos}
|
|
680
|
+
onToggle={handleToggle}
|
|
681
|
+
onDelete={handleDelete}
|
|
682
|
+
refreshControl={
|
|
683
|
+
<RefreshControl refreshing={loading} onRefresh={fetchTodos} />
|
|
684
|
+
}
|
|
685
|
+
/>
|
|
686
|
+
</View>
|
|
687
|
+
);
|
|
688
|
+
};
|
|
689
|
+
|
|
690
|
+
const styles = StyleSheet.create({
|
|
691
|
+
container: {
|
|
692
|
+
flex: 1,
|
|
693
|
+
},
|
|
694
|
+
});
|
|
695
|
+
```
|
|
696
|
+
|
|
697
|
+
### 12. Dumb Components (Presentation)
|
|
698
|
+
|
|
699
|
+
```typescript
|
|
700
|
+
// presentation/components/dumb/todo-list.component.tsx
|
|
701
|
+
|
|
702
|
+
import React, { memo, ReactElement } from 'react';
|
|
703
|
+
import { FlatList, StyleSheet, RefreshControl } from 'react-native';
|
|
704
|
+
import { TodoDto } from '@/application/dtos/todo.dto';
|
|
705
|
+
import { TodoItem } from './todo-item.component';
|
|
706
|
+
|
|
707
|
+
interface TodoListProps {
|
|
708
|
+
todos: TodoDto[];
|
|
709
|
+
onToggle: (id: string) => void;
|
|
710
|
+
onDelete: (id: string) => void;
|
|
711
|
+
refreshControl?: ReactElement<typeof RefreshControl>;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
export const TodoList: React.FC<TodoListProps> = memo(({
|
|
715
|
+
todos,
|
|
716
|
+
onToggle,
|
|
717
|
+
onDelete,
|
|
718
|
+
refreshControl
|
|
719
|
+
}) => {
|
|
720
|
+
return (
|
|
721
|
+
<FlatList
|
|
722
|
+
data={todos}
|
|
723
|
+
keyExtractor={(item) => item.id}
|
|
724
|
+
renderItem={({ item }) => (
|
|
725
|
+
<TodoItem
|
|
726
|
+
todo={item}
|
|
727
|
+
onToggle={onToggle}
|
|
728
|
+
onDelete={onDelete}
|
|
729
|
+
/>
|
|
730
|
+
)}
|
|
731
|
+
refreshControl={refreshControl}
|
|
732
|
+
contentContainerStyle={styles.content}
|
|
733
|
+
/>
|
|
734
|
+
);
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
TodoList.displayName = 'TodoList';
|
|
738
|
+
|
|
739
|
+
const styles = StyleSheet.create({
|
|
740
|
+
content: {
|
|
741
|
+
padding: 16,
|
|
742
|
+
},
|
|
743
|
+
});
|
|
744
|
+
```
|
|
745
|
+
|
|
746
|
+
```typescript
|
|
747
|
+
// presentation/components/dumb/todo-item.component.tsx
|
|
748
|
+
|
|
749
|
+
import React, { memo, useCallback } from 'react';
|
|
750
|
+
import { View, Text, Pressable, StyleSheet } from 'react-native';
|
|
751
|
+
import { TodoDto } from '@/application/dtos/todo.dto';
|
|
752
|
+
|
|
753
|
+
interface TodoItemProps {
|
|
754
|
+
todo: TodoDto;
|
|
755
|
+
onToggle: (id: string) => void;
|
|
756
|
+
onDelete: (id: string) => void;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
export const TodoItem: React.FC<TodoItemProps> = memo(({ todo, onToggle, onDelete }) => {
|
|
760
|
+
const handleToggle = useCallback(() => {
|
|
761
|
+
onToggle(todo.id);
|
|
762
|
+
}, [onToggle, todo.id]);
|
|
763
|
+
|
|
764
|
+
const handleDelete = useCallback(() => {
|
|
765
|
+
onDelete(todo.id);
|
|
766
|
+
}, [onDelete, todo.id]);
|
|
767
|
+
|
|
768
|
+
return (
|
|
769
|
+
<View style={styles.container}>
|
|
770
|
+
<Pressable
|
|
771
|
+
onPress={handleToggle}
|
|
772
|
+
style={styles.checkbox}
|
|
773
|
+
accessibilityRole="checkbox"
|
|
774
|
+
accessibilityState={{ checked: todo.completed }}
|
|
775
|
+
accessibilityLabel={`Mark ${todo.title} as ${todo.completed ? 'incomplete' : 'complete'}`}
|
|
776
|
+
>
|
|
777
|
+
<View style={[styles.checkboxInner, todo.completed && styles.checked]} />
|
|
778
|
+
</Pressable>
|
|
779
|
+
|
|
780
|
+
<Text style={[styles.title, todo.completed && styles.completedTitle]}>
|
|
781
|
+
{todo.title}
|
|
782
|
+
</Text>
|
|
783
|
+
|
|
784
|
+
<Pressable
|
|
785
|
+
onPress={handleDelete}
|
|
786
|
+
style={styles.deleteButton}
|
|
787
|
+
accessibilityRole="button"
|
|
788
|
+
accessibilityLabel={`Delete ${todo.title}`}
|
|
789
|
+
>
|
|
790
|
+
<Text style={styles.deleteText}>x</Text>
|
|
791
|
+
</Pressable>
|
|
792
|
+
</View>
|
|
793
|
+
);
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
TodoItem.displayName = 'TodoItem';
|
|
797
|
+
|
|
798
|
+
const styles = StyleSheet.create({
|
|
799
|
+
container: {
|
|
800
|
+
flexDirection: 'row',
|
|
801
|
+
alignItems: 'center',
|
|
802
|
+
padding: 16,
|
|
803
|
+
backgroundColor: '#fff',
|
|
804
|
+
borderRadius: 8,
|
|
805
|
+
marginBottom: 8,
|
|
806
|
+
shadowColor: '#000',
|
|
807
|
+
shadowOffset: { width: 0, height: 1 },
|
|
808
|
+
shadowOpacity: 0.1,
|
|
809
|
+
shadowRadius: 2,
|
|
810
|
+
elevation: 2,
|
|
811
|
+
},
|
|
812
|
+
checkbox: {
|
|
813
|
+
width: 24,
|
|
814
|
+
height: 24,
|
|
815
|
+
borderRadius: 12,
|
|
816
|
+
borderWidth: 2,
|
|
817
|
+
borderColor: '#007AFF',
|
|
818
|
+
justifyContent: 'center',
|
|
819
|
+
alignItems: 'center',
|
|
820
|
+
marginRight: 12,
|
|
821
|
+
},
|
|
822
|
+
checkboxInner: {
|
|
823
|
+
width: 12,
|
|
824
|
+
height: 12,
|
|
825
|
+
borderRadius: 6,
|
|
826
|
+
},
|
|
827
|
+
checked: {
|
|
828
|
+
backgroundColor: '#007AFF',
|
|
829
|
+
},
|
|
830
|
+
title: {
|
|
831
|
+
flex: 1,
|
|
832
|
+
fontSize: 16,
|
|
833
|
+
color: '#333',
|
|
834
|
+
},
|
|
835
|
+
completedTitle: {
|
|
836
|
+
textDecorationLine: 'line-through',
|
|
837
|
+
color: '#999',
|
|
838
|
+
},
|
|
839
|
+
deleteButton: {
|
|
840
|
+
padding: 8,
|
|
841
|
+
},
|
|
842
|
+
deleteText: {
|
|
843
|
+
fontSize: 24,
|
|
844
|
+
color: '#FF3B30',
|
|
845
|
+
fontWeight: 'bold',
|
|
846
|
+
},
|
|
847
|
+
});
|
|
848
|
+
```
|
|
849
|
+
|
|
850
|
+
```typescript
|
|
851
|
+
// presentation/components/dumb/todo-item.component.test.tsx
|
|
852
|
+
|
|
853
|
+
import React from 'react';
|
|
854
|
+
import { render, fireEvent } from '@testing-library/react-native';
|
|
855
|
+
import { TodoItem } from './todo-item.component';
|
|
856
|
+
import { TodoDto } from '@/application/dtos/todo.dto';
|
|
857
|
+
|
|
858
|
+
describe('TodoItem', () => {
|
|
859
|
+
const mockTodo: TodoDto = {
|
|
860
|
+
id: '1',
|
|
861
|
+
title: 'Test Todo',
|
|
862
|
+
completed: false,
|
|
863
|
+
createdAt: new Date().toISOString(),
|
|
864
|
+
};
|
|
865
|
+
|
|
866
|
+
it('should render todo title', () => {
|
|
867
|
+
const { getByText } = render(
|
|
868
|
+
<TodoItem todo={mockTodo} onToggle={jest.fn()} onDelete={jest.fn()} />
|
|
869
|
+
);
|
|
870
|
+
|
|
871
|
+
expect(getByText('Test Todo')).toBeTruthy();
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
it('should call onToggle when checkbox pressed', () => {
|
|
875
|
+
const onToggle = jest.fn();
|
|
876
|
+
const { getByRole } = render(
|
|
877
|
+
<TodoItem todo={mockTodo} onToggle={onToggle} onDelete={jest.fn()} />
|
|
878
|
+
);
|
|
879
|
+
|
|
880
|
+
fireEvent.press(getByRole('checkbox'));
|
|
881
|
+
expect(onToggle).toHaveBeenCalledWith('1');
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
it('should call onDelete when delete button pressed', () => {
|
|
885
|
+
const onDelete = jest.fn();
|
|
886
|
+
const { getByLabelText } = render(
|
|
887
|
+
<TodoItem todo={mockTodo} onToggle={jest.fn()} onDelete={onDelete} />
|
|
888
|
+
);
|
|
889
|
+
|
|
890
|
+
fireEvent.press(getByLabelText('Delete Test Todo'));
|
|
891
|
+
expect(onDelete).toHaveBeenCalledWith('1');
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
it('should apply completed styles when todo is completed', () => {
|
|
895
|
+
const completedTodo = { ...mockTodo, completed: true };
|
|
896
|
+
const { getByText } = render(
|
|
897
|
+
<TodoItem todo={completedTodo} onToggle={jest.fn()} onDelete={jest.fn()} />
|
|
898
|
+
);
|
|
899
|
+
|
|
900
|
+
const titleElement = getByText('Test Todo');
|
|
901
|
+
expect(titleElement.props.style).toContainEqual(
|
|
902
|
+
expect.objectContaining({ textDecorationLine: 'line-through' })
|
|
903
|
+
);
|
|
904
|
+
});
|
|
905
|
+
});
|
|
906
|
+
```
|
|
907
|
+
|
|
908
|
+
### 13. Expo Router Layout
|
|
909
|
+
|
|
910
|
+
```typescript
|
|
911
|
+
// app/_layout.tsx
|
|
912
|
+
|
|
913
|
+
import { Stack } from 'expo-router';
|
|
914
|
+
import { StatusBar } from 'expo-status-bar';
|
|
915
|
+
import { ContainerProvider } from '@/infrastructure/di/container';
|
|
916
|
+
|
|
917
|
+
export default function RootLayout() {
|
|
918
|
+
return (
|
|
919
|
+
<ContainerProvider>
|
|
920
|
+
<StatusBar style="auto" />
|
|
921
|
+
<Stack
|
|
922
|
+
screenOptions={{
|
|
923
|
+
headerStyle: { backgroundColor: '#007AFF' },
|
|
924
|
+
headerTintColor: '#fff',
|
|
925
|
+
headerTitleStyle: { fontWeight: 'bold' },
|
|
926
|
+
}}
|
|
927
|
+
/>
|
|
928
|
+
</ContainerProvider>
|
|
929
|
+
);
|
|
930
|
+
}
|
|
931
|
+
```
|
|
932
|
+
|
|
933
|
+
```typescript
|
|
934
|
+
// app/index.tsx
|
|
935
|
+
|
|
936
|
+
import { SafeAreaView, StyleSheet } from 'react-native';
|
|
937
|
+
import { Stack } from 'expo-router';
|
|
938
|
+
import { TodoListContainer } from '@/presentation/components/smart/todo-list.container';
|
|
939
|
+
|
|
940
|
+
export default function HomeScreen() {
|
|
941
|
+
return (
|
|
942
|
+
<SafeAreaView style={styles.container}>
|
|
943
|
+
<Stack.Screen options={{ title: 'My Todos' }} />
|
|
944
|
+
<TodoListContainer />
|
|
945
|
+
</SafeAreaView>
|
|
946
|
+
);
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
const styles = StyleSheet.create({
|
|
950
|
+
container: {
|
|
951
|
+
flex: 1,
|
|
952
|
+
backgroundColor: '#f5f5f5',
|
|
953
|
+
},
|
|
954
|
+
});
|
|
955
|
+
```
|
|
956
|
+
|
|
957
|
+
## Checklist Clean Hexa Mobile
|
|
958
|
+
|
|
959
|
+
Avant de valider ton code, vérifie :
|
|
960
|
+
|
|
961
|
+
### Domain
|
|
962
|
+
- [ ] Aucun import React/Expo dans domain/
|
|
963
|
+
- [ ] Entities immuables avec méthodes qui retournent de nouvelles instances
|
|
964
|
+
- [ ] Validation dans les factories (static create)
|
|
965
|
+
- [ ] Ports = interfaces TypeScript pures
|
|
966
|
+
|
|
967
|
+
### Application
|
|
968
|
+
- [ ] Use cases = custom hooks
|
|
969
|
+
- [ ] Dépendances injectées en paramètre du hook
|
|
970
|
+
- [ ] États: loading, error, data, success
|
|
971
|
+
- [ ] Tests avec mocks des ports
|
|
972
|
+
|
|
973
|
+
### Infrastructure
|
|
974
|
+
- [ ] Adapters implémentent les ports
|
|
975
|
+
- [ ] Container avec React Context
|
|
976
|
+
- [ ] Pas de singletons exportés directement
|
|
977
|
+
- [ ] Tests des adapters
|
|
978
|
+
|
|
979
|
+
### Presentation
|
|
980
|
+
- [ ] Smart components connectent use cases aux dumb
|
|
981
|
+
- [ ] Dumb components = props only + memo
|
|
982
|
+
- [ ] FlatList pour les listes (pas ScrollView)
|
|
983
|
+
- [ ] Accessibilité (accessibilityLabel, accessibilityRole)
|
|
984
|
+
- [ ] Tests des dumb components
|