@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.
- package/README.md +204 -0
- package/bin/cli.js +35 -0
- package/package.json +19 -0
- package/standards/architecture-patterns.md +444 -0
- package/standards/backend-standards.md +812 -0
- package/standards/database-conventions.md +667 -0
- package/standards/frontend-standards.md +1199 -0
- package/standards/mobile-flutter-standards.md +1292 -0
- package/standards/mobile-react-native-standards.md +1288 -0
- package/standards/technical-preferences-ux.md +400 -0
- package/standards/technology-stack.md +294 -0
- package/standards/vite-config-standard.md +531 -0
|
@@ -0,0 +1,1292 @@
|
|
|
1
|
+
# Mobile Flutter 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).
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Technology Stack
|
|
8
|
+
|
|
9
|
+
### Core Technologies
|
|
10
|
+
|
|
11
|
+
| Category | Technology | Version | Notes |
|
|
12
|
+
|----------|------------|---------|-------|
|
|
13
|
+
| **SDK** | Flutter | 3.27+ | Stable channel only |
|
|
14
|
+
| **Language** | Dart | 3.6+ | Sound null safety, records, patterns |
|
|
15
|
+
| **State** | Riverpod | 3+ | Code-generated, annotation-based |
|
|
16
|
+
| **Navigation** | GoRouter | 14+ | Declarative, deep-link ready |
|
|
17
|
+
| **DI** | get_it + injectable | Latest | Service locator with code generation |
|
|
18
|
+
| **HTTP** | Dio | 5+ | With interceptors |
|
|
19
|
+
| **Local DB** | Drift | 2+ | Type-safe SQLite, reactive streams |
|
|
20
|
+
| **Secure Storage** | flutter_secure_storage | 9+ | Keychain / Keystore backed |
|
|
21
|
+
| **Models** | Freezed + json_serializable | Latest | Immutable models, sealed classes |
|
|
22
|
+
| **Validation** | formz | Latest | Type-safe form field validation |
|
|
23
|
+
| **Testing** | flutter_test + mocktail | Latest | Unit, widget, integration |
|
|
24
|
+
|
|
25
|
+
### Development Tools
|
|
26
|
+
|
|
27
|
+
| Tool | Purpose |
|
|
28
|
+
|------|---------|
|
|
29
|
+
| `flutter analyze` | Static analysis (strict) |
|
|
30
|
+
| `dart format` | Code formatting |
|
|
31
|
+
| `build_runner` | Code generation (Freezed, Riverpod, Drift) |
|
|
32
|
+
| `flutter_gen` | Type-safe asset generation |
|
|
33
|
+
| `very_good_cli` | Project scaffolding and CI |
|
|
34
|
+
| `patrol` | Integration and end-to-end testing |
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## Architecture
|
|
39
|
+
|
|
40
|
+
### Style: Clean Architecture + DDD + Feature-First
|
|
41
|
+
|
|
42
|
+
Same layered philosophy as the backend and frontend standards. Business logic drives every decision. Dependencies always point inward.
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
┌──────────────────────────────────────────────────────┐
|
|
46
|
+
│ PRESENTATION LAYER │
|
|
47
|
+
│ Widgets, Pages, Riverpod UI providers │
|
|
48
|
+
└──────────────────────────────────────────────────────┘
|
|
49
|
+
│
|
|
50
|
+
▼
|
|
51
|
+
┌──────────────────────────────────────────────────────┐
|
|
52
|
+
│ APPLICATION LAYER │
|
|
53
|
+
│ Use cases, state notifiers, mappers │
|
|
54
|
+
└──────────────────────────────────────────────────────┘
|
|
55
|
+
│
|
|
56
|
+
▼
|
|
57
|
+
┌──────────────────────────────────────────────────────┐
|
|
58
|
+
│ INFRASTRUCTURE LAYER │
|
|
59
|
+
│ Repository implementations, APIs, local DB │
|
|
60
|
+
└──────────────────────────────────────────────────────┘
|
|
61
|
+
│
|
|
62
|
+
▼
|
|
63
|
+
┌──────────────────────────────────────────────────────┐
|
|
64
|
+
│ DOMAIN LAYER │
|
|
65
|
+
│ Entities, value objects, repository interfaces │
|
|
66
|
+
└──────────────────────────────────────────────────────┘
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Folder Structure
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
lib/
|
|
73
|
+
├── main.dart # App entry point
|
|
74
|
+
├── app/
|
|
75
|
+
│ ├── app.dart # MaterialApp / CupertinoApp
|
|
76
|
+
│ ├── router/
|
|
77
|
+
│ │ └── app_router.dart # GoRouter configuration
|
|
78
|
+
│ ├── providers/
|
|
79
|
+
│ │ └── app_providers.dart # Root Riverpod overrides
|
|
80
|
+
│ └── di/
|
|
81
|
+
│ ├── injection.dart # get_it setup
|
|
82
|
+
│ └── injection.config.dart # Generated by injectable
|
|
83
|
+
│
|
|
84
|
+
├── modules/ # Business modules (DDD)
|
|
85
|
+
│ ├── sales/
|
|
86
|
+
│ │ ├── quotes/
|
|
87
|
+
│ │ │ ├── cart/
|
|
88
|
+
│ │ │ │ ├── domain/
|
|
89
|
+
│ │ │ │ │ ├── entities/
|
|
90
|
+
│ │ │ │ │ │ └── cart_item.dart
|
|
91
|
+
│ │ │ │ │ ├── repositories/
|
|
92
|
+
│ │ │ │ │ │ └── i_cart_repository.dart
|
|
93
|
+
│ │ │ │ │ ├── value_objects/
|
|
94
|
+
│ │ │ │ │ │ └── quantity.dart
|
|
95
|
+
│ │ │ │ │ └── failures/
|
|
96
|
+
│ │ │ │ │ └── cart_failure.dart
|
|
97
|
+
│ │ │ │ ├── application/
|
|
98
|
+
│ │ │ │ │ ├── use_cases/
|
|
99
|
+
│ │ │ │ │ │ ├── add_to_cart.dart
|
|
100
|
+
│ │ │ │ │ │ └── remove_from_cart.dart
|
|
101
|
+
│ │ │ │ │ └── providers/
|
|
102
|
+
│ │ │ │ │ └── cart_provider.dart
|
|
103
|
+
│ │ │ │ ├── infrastructure/
|
|
104
|
+
│ │ │ │ │ ├── repositories/
|
|
105
|
+
│ │ │ │ │ │ └── cart_repository.dart
|
|
106
|
+
│ │ │ │ │ ├── datasources/
|
|
107
|
+
│ │ │ │ │ │ ├── cart_remote_datasource.dart
|
|
108
|
+
│ │ │ │ │ │ └── cart_local_datasource.dart
|
|
109
|
+
│ │ │ │ │ └── models/
|
|
110
|
+
│ │ │ │ │ └── cart_item_model.dart
|
|
111
|
+
│ │ │ │ └── presentation/
|
|
112
|
+
│ │ │ │ ├── pages/
|
|
113
|
+
│ │ │ │ │ └── cart_page.dart
|
|
114
|
+
│ │ │ │ └── widgets/
|
|
115
|
+
│ │ │ │ └── cart_item_widget.dart
|
|
116
|
+
│ │ │ └── products/
|
|
117
|
+
│ │ └── billing/
|
|
118
|
+
│ ├── inventory/
|
|
119
|
+
│ └── users/
|
|
120
|
+
│
|
|
121
|
+
├── shared/
|
|
122
|
+
│ ├── widgets/ # Reusable UI widgets
|
|
123
|
+
│ │ ├── app_button.dart
|
|
124
|
+
│ │ ├── app_text_field.dart
|
|
125
|
+
│ │ └── loading_skeleton.dart
|
|
126
|
+
│ ├── extensions/ # Dart extensions
|
|
127
|
+
│ │ ├── context_extensions.dart
|
|
128
|
+
│ │ └── string_extensions.dart
|
|
129
|
+
│ ├── utils/
|
|
130
|
+
│ │ ├── validators.dart
|
|
131
|
+
│ │ └── formatters.dart
|
|
132
|
+
│ ├── theme/
|
|
133
|
+
│ │ ├── app_theme.dart
|
|
134
|
+
│ │ ├── app_colors.dart
|
|
135
|
+
│ │ ├── app_typography.dart
|
|
136
|
+
│ │ └── app_spacing.dart
|
|
137
|
+
│ ├── constants/
|
|
138
|
+
│ │ └── app_constants.dart
|
|
139
|
+
│ └── failures/
|
|
140
|
+
│ └── failure.dart # Base failure sealed class
|
|
141
|
+
│
|
|
142
|
+
├── core/
|
|
143
|
+
│ ├── network/
|
|
144
|
+
│ │ ├── dio_client.dart
|
|
145
|
+
│ │ └── interceptors/
|
|
146
|
+
│ │ ├── auth_interceptor.dart
|
|
147
|
+
│ │ └── logging_interceptor.dart
|
|
148
|
+
│ ├── storage/
|
|
149
|
+
│ │ ├── secure_storage.dart
|
|
150
|
+
│ │ └── app_database.dart # Drift database
|
|
151
|
+
│ └── services/
|
|
152
|
+
│ └── analytics_service.dart
|
|
153
|
+
│
|
|
154
|
+
└── l10n/ # Localization
|
|
155
|
+
├── app_en.arb
|
|
156
|
+
└── app_es.arb
|
|
157
|
+
|
|
158
|
+
test/
|
|
159
|
+
├── modules/
|
|
160
|
+
│ └── sales/
|
|
161
|
+
│ └── quotes/
|
|
162
|
+
│ └── cart/
|
|
163
|
+
│ ├── domain/
|
|
164
|
+
│ ├── application/
|
|
165
|
+
│ └── infrastructure/
|
|
166
|
+
├── shared/
|
|
167
|
+
└── helpers/
|
|
168
|
+
└── test_helpers.dart
|
|
169
|
+
|
|
170
|
+
integration_test/
|
|
171
|
+
└── features/
|
|
172
|
+
└── cart_flow_test.dart
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
---
|
|
176
|
+
|
|
177
|
+
## Domain Layer
|
|
178
|
+
|
|
179
|
+
### Entities
|
|
180
|
+
|
|
181
|
+
Entities are immutable. Use `Freezed` for boilerplate elimination and value equality.
|
|
182
|
+
|
|
183
|
+
```dart
|
|
184
|
+
// modules/sales/quotes/cart/domain/entities/cart_item.dart
|
|
185
|
+
import 'package:freezed_annotation/freezed_annotation.dart';
|
|
186
|
+
|
|
187
|
+
part 'cart_item.freezed.dart';
|
|
188
|
+
|
|
189
|
+
@freezed
|
|
190
|
+
class CartItem with _$CartItem {
|
|
191
|
+
const CartItem._(); // Custom methods require private constructor
|
|
192
|
+
|
|
193
|
+
const factory CartItem({
|
|
194
|
+
required String id,
|
|
195
|
+
required String productId,
|
|
196
|
+
required String productName,
|
|
197
|
+
required int quantity,
|
|
198
|
+
required double unitPrice,
|
|
199
|
+
}) = _CartItem;
|
|
200
|
+
|
|
201
|
+
double get totalPrice => unitPrice * quantity;
|
|
202
|
+
bool get isValid => quantity > 0 && unitPrice > 0;
|
|
203
|
+
}
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
### Value Objects
|
|
207
|
+
|
|
208
|
+
Value objects enforce business invariants at construction time and return failures, never throw.
|
|
209
|
+
|
|
210
|
+
```dart
|
|
211
|
+
// modules/sales/quotes/cart/domain/value_objects/quantity.dart
|
|
212
|
+
import 'package:dartz/dartz.dart';
|
|
213
|
+
import '../failures/cart_failure.dart';
|
|
214
|
+
|
|
215
|
+
class Quantity {
|
|
216
|
+
final Either<CartFailure, int> value;
|
|
217
|
+
|
|
218
|
+
factory Quantity(int input) {
|
|
219
|
+
return Quantity._(
|
|
220
|
+
input >= 1
|
|
221
|
+
? right(input)
|
|
222
|
+
: left(const CartFailure.invalidQuantity()),
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const Quantity._(this.value);
|
|
227
|
+
|
|
228
|
+
int getOrCrash() => value.fold(
|
|
229
|
+
(f) => throw Exception('Unvalidated quantity: $f'),
|
|
230
|
+
(v) => v,
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
bool isValid() => value.isRight();
|
|
234
|
+
}
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
### Failures (Sealed Classes)
|
|
238
|
+
|
|
239
|
+
Use Dart 3 sealed classes for exhaustive failure modeling. Never throw domain exceptions.
|
|
240
|
+
|
|
241
|
+
```dart
|
|
242
|
+
// modules/sales/quotes/cart/domain/failures/cart_failure.dart
|
|
243
|
+
sealed class CartFailure {
|
|
244
|
+
const CartFailure();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
class InvalidQuantity extends CartFailure {
|
|
248
|
+
const InvalidQuantity();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
class ProductNotFound extends CartFailure {
|
|
252
|
+
final String productId;
|
|
253
|
+
const ProductNotFound(this.productId);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
class CartLimitExceeded extends CartFailure {
|
|
257
|
+
final int maxItems;
|
|
258
|
+
const CartLimitExceeded(this.maxItems);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
class ServerFailure extends CartFailure {
|
|
262
|
+
final String message;
|
|
263
|
+
const ServerFailure(this.message);
|
|
264
|
+
}
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
### Repository Interfaces
|
|
268
|
+
|
|
269
|
+
```dart
|
|
270
|
+
// modules/sales/quotes/cart/domain/repositories/i_cart_repository.dart
|
|
271
|
+
import 'package:dartz/dartz.dart';
|
|
272
|
+
import '../entities/cart_item.dart';
|
|
273
|
+
import '../failures/cart_failure.dart';
|
|
274
|
+
|
|
275
|
+
abstract interface class ICartRepository {
|
|
276
|
+
Future<Either<CartFailure, List<CartItem>>> getCartItems();
|
|
277
|
+
Future<Either<CartFailure, CartItem>> addItem({
|
|
278
|
+
required String productId,
|
|
279
|
+
required int quantity,
|
|
280
|
+
});
|
|
281
|
+
Future<Either<CartFailure, Unit>> removeItem(String itemId);
|
|
282
|
+
Future<Either<CartFailure, Unit>> clearCart();
|
|
283
|
+
Stream<List<CartItem>> watchCartItems();
|
|
284
|
+
}
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
---
|
|
288
|
+
|
|
289
|
+
## Application Layer
|
|
290
|
+
|
|
291
|
+
### Use Cases
|
|
292
|
+
|
|
293
|
+
Each use case is a single callable class. One use case per file.
|
|
294
|
+
|
|
295
|
+
```dart
|
|
296
|
+
// modules/sales/quotes/cart/application/use_cases/add_to_cart.dart
|
|
297
|
+
import 'package:dartz/dartz.dart';
|
|
298
|
+
import 'package:injectable/injectable.dart';
|
|
299
|
+
import '../../domain/entities/cart_item.dart';
|
|
300
|
+
import '../../domain/failures/cart_failure.dart';
|
|
301
|
+
import '../../domain/repositories/i_cart_repository.dart';
|
|
302
|
+
import '../../domain/value_objects/quantity.dart';
|
|
303
|
+
|
|
304
|
+
@injectable
|
|
305
|
+
class AddToCart {
|
|
306
|
+
final ICartRepository _repository;
|
|
307
|
+
|
|
308
|
+
const AddToCart(this._repository);
|
|
309
|
+
|
|
310
|
+
Future<Either<CartFailure, CartItem>> call({
|
|
311
|
+
required String productId,
|
|
312
|
+
required int quantity,
|
|
313
|
+
}) async {
|
|
314
|
+
final quantityVO = Quantity(quantity);
|
|
315
|
+
|
|
316
|
+
if (!quantityVO.isValid()) {
|
|
317
|
+
return left(const InvalidQuantity());
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return _repository.addItem(
|
|
321
|
+
productId: productId,
|
|
322
|
+
quantity: quantityVO.getOrCrash(),
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
### Riverpod Providers (State Notifiers)
|
|
329
|
+
|
|
330
|
+
Use `@riverpod` annotation with code generation. `AsyncNotifier` for async state, `Notifier` for sync.
|
|
331
|
+
|
|
332
|
+
```dart
|
|
333
|
+
// modules/sales/quotes/cart/application/providers/cart_provider.dart
|
|
334
|
+
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
|
335
|
+
import '../../domain/entities/cart_item.dart';
|
|
336
|
+
import '../../domain/failures/cart_failure.dart';
|
|
337
|
+
import '../use_cases/add_to_cart.dart';
|
|
338
|
+
import '../use_cases/remove_from_cart.dart';
|
|
339
|
+
|
|
340
|
+
part 'cart_provider.g.dart';
|
|
341
|
+
|
|
342
|
+
@Riverpod(keepAlive: true)
|
|
343
|
+
class CartNotifier extends _$CartNotifier {
|
|
344
|
+
late final AddToCart _addToCart;
|
|
345
|
+
late final RemoveFromCart _removeFromCart;
|
|
346
|
+
|
|
347
|
+
@override
|
|
348
|
+
Future<List<CartItem>> build() async {
|
|
349
|
+
_addToCart = ref.read(addToCartProvider);
|
|
350
|
+
_removeFromCart = ref.read(removeFromCartProvider);
|
|
351
|
+
|
|
352
|
+
// Watch reactive stream from local DB
|
|
353
|
+
final repository = ref.read(cartRepositoryProvider);
|
|
354
|
+
ref.listen(
|
|
355
|
+
Stream.fromFuture(repository.getCartItems().then(
|
|
356
|
+
(e) => e.fold((_) => <CartItem>[], (v) => v),
|
|
357
|
+
)).asStream().asBroadcastStream(),
|
|
358
|
+
(_, __) {},
|
|
359
|
+
);
|
|
360
|
+
|
|
361
|
+
final result = await repository.getCartItems();
|
|
362
|
+
return result.fold(
|
|
363
|
+
(failure) => throw _mapFailure(failure),
|
|
364
|
+
(items) => items,
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
Future<void> addItem({
|
|
369
|
+
required String productId,
|
|
370
|
+
required int quantity,
|
|
371
|
+
}) async {
|
|
372
|
+
state = const AsyncLoading();
|
|
373
|
+
final result = await _addToCart(
|
|
374
|
+
productId: productId,
|
|
375
|
+
quantity: quantity,
|
|
376
|
+
);
|
|
377
|
+
state = result.fold(
|
|
378
|
+
(failure) => AsyncError(_mapFailure(failure), StackTrace.current),
|
|
379
|
+
(_) => AsyncData([...state.valueOrNull ?? [], _]),
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
Exception _mapFailure(CartFailure failure) => switch (failure) {
|
|
384
|
+
InvalidQuantity() => Exception('La cantidad debe ser mayor a cero'),
|
|
385
|
+
ProductNotFound(:final productId) =>
|
|
386
|
+
Exception('Producto $productId no encontrado'),
|
|
387
|
+
CartLimitExceeded(:final maxItems) =>
|
|
388
|
+
Exception('El carrito no puede tener más de $maxItems artículos'),
|
|
389
|
+
ServerFailure(:final message) => Exception(message),
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
---
|
|
395
|
+
|
|
396
|
+
## Infrastructure Layer
|
|
397
|
+
|
|
398
|
+
### Models (DTO + JSON)
|
|
399
|
+
|
|
400
|
+
```dart
|
|
401
|
+
// modules/sales/quotes/cart/infrastructure/models/cart_item_model.dart
|
|
402
|
+
import 'package:freezed_annotation/freezed_annotation.dart';
|
|
403
|
+
import '../../domain/entities/cart_item.dart';
|
|
404
|
+
|
|
405
|
+
part 'cart_item_model.freezed.dart';
|
|
406
|
+
part 'cart_item_model.g.dart';
|
|
407
|
+
|
|
408
|
+
@freezed
|
|
409
|
+
class CartItemModel with _$CartItemModel {
|
|
410
|
+
const CartItemModel._();
|
|
411
|
+
|
|
412
|
+
const factory CartItemModel({
|
|
413
|
+
required String id,
|
|
414
|
+
@JsonKey(name: 'product_id') required String productId,
|
|
415
|
+
@JsonKey(name: 'product_name') required String productName,
|
|
416
|
+
required int quantity,
|
|
417
|
+
@JsonKey(name: 'unit_price') required double unitPrice,
|
|
418
|
+
}) = _CartItemModel;
|
|
419
|
+
|
|
420
|
+
factory CartItemModel.fromJson(Map<String, dynamic> json) =>
|
|
421
|
+
_$CartItemModelFromJson(json);
|
|
422
|
+
|
|
423
|
+
factory CartItemModel.fromDomain(CartItem entity) => CartItemModel(
|
|
424
|
+
id: entity.id,
|
|
425
|
+
productId: entity.productId,
|
|
426
|
+
productName: entity.productName,
|
|
427
|
+
quantity: entity.quantity,
|
|
428
|
+
unitPrice: entity.unitPrice,
|
|
429
|
+
);
|
|
430
|
+
|
|
431
|
+
CartItem toDomain() => CartItem(
|
|
432
|
+
id: id,
|
|
433
|
+
productId: productId,
|
|
434
|
+
productName: productName,
|
|
435
|
+
quantity: quantity,
|
|
436
|
+
unitPrice: unitPrice,
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
### Repository Implementation
|
|
442
|
+
|
|
443
|
+
```dart
|
|
444
|
+
// modules/sales/quotes/cart/infrastructure/repositories/cart_repository.dart
|
|
445
|
+
import 'package:dartz/dartz.dart';
|
|
446
|
+
import 'package:injectable/injectable.dart';
|
|
447
|
+
import '../../domain/entities/cart_item.dart';
|
|
448
|
+
import '../../domain/failures/cart_failure.dart';
|
|
449
|
+
import '../../domain/repositories/i_cart_repository.dart';
|
|
450
|
+
import '../datasources/cart_remote_datasource.dart';
|
|
451
|
+
import '../datasources/cart_local_datasource.dart';
|
|
452
|
+
|
|
453
|
+
@LazySingleton(as: ICartRepository)
|
|
454
|
+
class CartRepository implements ICartRepository {
|
|
455
|
+
final CartRemoteDatasource _remote;
|
|
456
|
+
final CartLocalDatasource _local;
|
|
457
|
+
|
|
458
|
+
const CartRepository(this._remote, this._local);
|
|
459
|
+
|
|
460
|
+
@override
|
|
461
|
+
Future<Either<CartFailure, List<CartItem>>> getCartItems() async {
|
|
462
|
+
try {
|
|
463
|
+
final local = await _local.getCartItems();
|
|
464
|
+
if (local.isNotEmpty) return right(local);
|
|
465
|
+
|
|
466
|
+
final remote = await _remote.getCartItems();
|
|
467
|
+
await _local.cacheCartItems(remote);
|
|
468
|
+
return right(remote.map((m) => m.toDomain()).toList());
|
|
469
|
+
} on NetworkException catch (e) {
|
|
470
|
+
return left(ServerFailure(e.message));
|
|
471
|
+
} catch (_) {
|
|
472
|
+
return left(const ServerFailure('Error inesperado'));
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
@override
|
|
477
|
+
Future<Either<CartFailure, CartItem>> addItem({
|
|
478
|
+
required String productId,
|
|
479
|
+
required int quantity,
|
|
480
|
+
}) async {
|
|
481
|
+
try {
|
|
482
|
+
final model = await _remote.addItem(
|
|
483
|
+
productId: productId,
|
|
484
|
+
quantity: quantity,
|
|
485
|
+
);
|
|
486
|
+
await _local.saveItem(model);
|
|
487
|
+
return right(model.toDomain());
|
|
488
|
+
} on NetworkException catch (e) {
|
|
489
|
+
return left(ServerFailure(e.message));
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
@override
|
|
494
|
+
Future<Either<CartFailure, Unit>> removeItem(String itemId) async {
|
|
495
|
+
try {
|
|
496
|
+
await _remote.removeItem(itemId);
|
|
497
|
+
await _local.deleteItem(itemId);
|
|
498
|
+
return right(unit);
|
|
499
|
+
} on NetworkException catch (e) {
|
|
500
|
+
return left(ServerFailure(e.message));
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
@override
|
|
505
|
+
Future<Either<CartFailure, Unit>> clearCart() async {
|
|
506
|
+
try {
|
|
507
|
+
await _remote.clearCart();
|
|
508
|
+
await _local.clearAll();
|
|
509
|
+
return right(unit);
|
|
510
|
+
} on NetworkException catch (e) {
|
|
511
|
+
return left(ServerFailure(e.message));
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
@override
|
|
516
|
+
Stream<List<CartItem>> watchCartItems() =>
|
|
517
|
+
_local.watchCartItems().map((items) => items.map((m) => m.toDomain()).toList());
|
|
518
|
+
}
|
|
519
|
+
```
|
|
520
|
+
|
|
521
|
+
### Dio HTTP Client
|
|
522
|
+
|
|
523
|
+
```dart
|
|
524
|
+
// core/network/dio_client.dart
|
|
525
|
+
import 'package:dio/dio.dart';
|
|
526
|
+
import 'package:injectable/injectable.dart';
|
|
527
|
+
import 'interceptors/auth_interceptor.dart';
|
|
528
|
+
import 'interceptors/logging_interceptor.dart';
|
|
529
|
+
|
|
530
|
+
@singleton
|
|
531
|
+
class DioClient {
|
|
532
|
+
late final Dio dio;
|
|
533
|
+
|
|
534
|
+
DioClient(AuthInterceptor authInterceptor) {
|
|
535
|
+
dio = Dio(
|
|
536
|
+
BaseOptions(
|
|
537
|
+
baseUrl: const String.fromEnvironment('API_BASE_URL'),
|
|
538
|
+
connectTimeout: const Duration(seconds: 10),
|
|
539
|
+
receiveTimeout: const Duration(seconds: 30),
|
|
540
|
+
contentType: 'application/json',
|
|
541
|
+
),
|
|
542
|
+
);
|
|
543
|
+
|
|
544
|
+
dio.interceptors.addAll([
|
|
545
|
+
authInterceptor,
|
|
546
|
+
LoggingInterceptor(),
|
|
547
|
+
RetryInterceptor(dio: dio, retries: 3),
|
|
548
|
+
]);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
```
|
|
552
|
+
|
|
553
|
+
---
|
|
554
|
+
|
|
555
|
+
## Presentation Layer
|
|
556
|
+
|
|
557
|
+
### Pages
|
|
558
|
+
|
|
559
|
+
Pages are thin orchestrators. They connect Riverpod state to the widget tree and handle routing.
|
|
560
|
+
|
|
561
|
+
```dart
|
|
562
|
+
// modules/sales/quotes/cart/presentation/pages/cart_page.dart
|
|
563
|
+
import 'package:flutter/material.dart';
|
|
564
|
+
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
565
|
+
import 'package:go_router/go_router.dart';
|
|
566
|
+
import '../../application/providers/cart_provider.dart';
|
|
567
|
+
import '../widgets/cart_item_widget.dart';
|
|
568
|
+
import '../../../../shared/widgets/loading_skeleton.dart';
|
|
569
|
+
import '../../../../shared/widgets/error_view.dart';
|
|
570
|
+
|
|
571
|
+
class CartPage extends ConsumerWidget {
|
|
572
|
+
const CartPage({super.key});
|
|
573
|
+
|
|
574
|
+
@override
|
|
575
|
+
Widget build(BuildContext context, WidgetRef ref) {
|
|
576
|
+
final cartState = ref.watch(cartNotifierProvider);
|
|
577
|
+
|
|
578
|
+
return Scaffold(
|
|
579
|
+
appBar: AppBar(title: const Text('Carrito')),
|
|
580
|
+
body: switch (cartState) {
|
|
581
|
+
AsyncLoading() => const LoadingSkeleton(),
|
|
582
|
+
AsyncError(:final error) => ErrorView(
|
|
583
|
+
message: error.toString(),
|
|
584
|
+
onRetry: () => ref.invalidate(cartNotifierProvider),
|
|
585
|
+
),
|
|
586
|
+
AsyncData(:final value) when value.isEmpty => const _EmptyCart(),
|
|
587
|
+
AsyncData(:final value) => ListView.builder(
|
|
588
|
+
itemCount: value.length,
|
|
589
|
+
itemBuilder: (context, index) => CartItemWidget(
|
|
590
|
+
item: value[index],
|
|
591
|
+
onRemove: (id) => ref
|
|
592
|
+
.read(cartNotifierProvider.notifier)
|
|
593
|
+
.removeItem(id),
|
|
594
|
+
),
|
|
595
|
+
),
|
|
596
|
+
},
|
|
597
|
+
bottomNavigationBar: _CartSummaryBar(
|
|
598
|
+
onCheckout: () => context.push('/sales/checkout'),
|
|
599
|
+
),
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
```
|
|
604
|
+
|
|
605
|
+
### Widgets
|
|
606
|
+
|
|
607
|
+
```dart
|
|
608
|
+
// modules/sales/quotes/cart/presentation/widgets/cart_item_widget.dart
|
|
609
|
+
import 'package:flutter/material.dart';
|
|
610
|
+
import '../../domain/entities/cart_item.dart';
|
|
611
|
+
|
|
612
|
+
class CartItemWidget extends StatelessWidget {
|
|
613
|
+
final CartItem item;
|
|
614
|
+
final void Function(String id) onRemove;
|
|
615
|
+
|
|
616
|
+
const CartItemWidget({
|
|
617
|
+
super.key,
|
|
618
|
+
required this.item,
|
|
619
|
+
required this.onRemove,
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
@override
|
|
623
|
+
Widget build(BuildContext context) {
|
|
624
|
+
return ListTile(
|
|
625
|
+
key: ValueKey(item.id),
|
|
626
|
+
title: Text(item.productName),
|
|
627
|
+
subtitle: Text('Cantidad: ${item.quantity}'),
|
|
628
|
+
trailing: Row(
|
|
629
|
+
mainAxisSize: MainAxisSize.min,
|
|
630
|
+
children: [
|
|
631
|
+
Text('\$${item.totalPrice.toStringAsFixed(2)}'),
|
|
632
|
+
IconButton(
|
|
633
|
+
icon: const Icon(Icons.delete_outline),
|
|
634
|
+
tooltip: 'Eliminar del carrito',
|
|
635
|
+
onPressed: () => onRemove(item.id),
|
|
636
|
+
),
|
|
637
|
+
],
|
|
638
|
+
),
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
```
|
|
643
|
+
|
|
644
|
+
---
|
|
645
|
+
|
|
646
|
+
## Navigation (GoRouter)
|
|
647
|
+
|
|
648
|
+
```dart
|
|
649
|
+
// app/router/app_router.dart
|
|
650
|
+
import 'package:flutter/material.dart';
|
|
651
|
+
import 'package:go_router/go_router.dart';
|
|
652
|
+
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
|
653
|
+
|
|
654
|
+
part 'app_router.g.dart';
|
|
655
|
+
|
|
656
|
+
@riverpod
|
|
657
|
+
GoRouter appRouter(AppRouterRef ref) {
|
|
658
|
+
final isAuthenticated = ref.watch(authStateProvider);
|
|
659
|
+
|
|
660
|
+
return GoRouter(
|
|
661
|
+
initialLocation: '/dashboard',
|
|
662
|
+
redirect: (context, state) {
|
|
663
|
+
final loggedIn = isAuthenticated.valueOrNull ?? false;
|
|
664
|
+
final isAuthRoute = state.matchedLocation.startsWith('/auth');
|
|
665
|
+
|
|
666
|
+
if (!loggedIn && !isAuthRoute) return '/auth/login';
|
|
667
|
+
if (loggedIn && isAuthRoute) return '/dashboard';
|
|
668
|
+
return null;
|
|
669
|
+
},
|
|
670
|
+
routes: [
|
|
671
|
+
GoRoute(
|
|
672
|
+
path: '/auth/login',
|
|
673
|
+
builder: (context, state) => const LoginPage(),
|
|
674
|
+
),
|
|
675
|
+
ShellRoute(
|
|
676
|
+
builder: (context, state, child) => AppShell(child: child),
|
|
677
|
+
routes: [
|
|
678
|
+
GoRoute(
|
|
679
|
+
path: '/dashboard',
|
|
680
|
+
builder: (context, state) => const DashboardPage(),
|
|
681
|
+
),
|
|
682
|
+
GoRoute(
|
|
683
|
+
path: '/sales/quotes',
|
|
684
|
+
builder: (context, state) => const QuotesPage(),
|
|
685
|
+
routes: [
|
|
686
|
+
GoRoute(
|
|
687
|
+
path: 'cart',
|
|
688
|
+
builder: (context, state) => const CartPage(),
|
|
689
|
+
),
|
|
690
|
+
],
|
|
691
|
+
),
|
|
692
|
+
GoRoute(
|
|
693
|
+
path: '/inventory/products',
|
|
694
|
+
builder: (context, state) => const ProductsPage(),
|
|
695
|
+
),
|
|
696
|
+
],
|
|
697
|
+
),
|
|
698
|
+
],
|
|
699
|
+
);
|
|
700
|
+
}
|
|
701
|
+
```
|
|
702
|
+
|
|
703
|
+
---
|
|
704
|
+
|
|
705
|
+
## Design System
|
|
706
|
+
|
|
707
|
+
Mirrors the shared design tokens from `technical-preferences-ux.md`.
|
|
708
|
+
|
|
709
|
+
### Colors
|
|
710
|
+
|
|
711
|
+
```dart
|
|
712
|
+
// shared/theme/app_colors.dart
|
|
713
|
+
import 'package:flutter/material.dart';
|
|
714
|
+
|
|
715
|
+
abstract final class AppColors {
|
|
716
|
+
// Primary brand: #0E79FD
|
|
717
|
+
static const primary50 = Color(0xFFEFF8FF);
|
|
718
|
+
static const primary100 = Color(0xFFDBF0FF);
|
|
719
|
+
static const primary200 = Color(0xFFBFE3FF);
|
|
720
|
+
static const primary300 = Color(0xFF93D2FF);
|
|
721
|
+
static const primary400 = Color(0xFF60B6FF);
|
|
722
|
+
static const primary500 = Color(0xFF0E79FD); // Main
|
|
723
|
+
static const primary600 = Color(0xFF0B6AE6);
|
|
724
|
+
static const primary700 = Color(0xFF0959C2);
|
|
725
|
+
static const primary800 = Color(0xFF0E4A9E);
|
|
726
|
+
static const primary900 = Color(0xFF123F80);
|
|
727
|
+
static const primary950 = Color(0xFF11274D);
|
|
728
|
+
|
|
729
|
+
// Tertiary brand: #154ca9
|
|
730
|
+
static const tertiary700 = Color(0xFF154CA9); // Main
|
|
731
|
+
|
|
732
|
+
// Secondary brand: #000000
|
|
733
|
+
static const secondary950 = Color(0xFF000000); // Main
|
|
734
|
+
|
|
735
|
+
// Surfaces
|
|
736
|
+
static const backgroundLight = Color(0xFFFFFFFF);
|
|
737
|
+
static const surfaceLight = Color(0xFFF8FAFC); // slate-50
|
|
738
|
+
static const borderLight = Color(0xFFE2E8F0); // slate-200
|
|
739
|
+
|
|
740
|
+
static const backgroundDark = Color(0xFF020617); // slate-950
|
|
741
|
+
static const surfaceDark = Color(0xFF0F172A); // slate-900
|
|
742
|
+
static const borderDark = Color(0xFF334155); // slate-700
|
|
743
|
+
|
|
744
|
+
// Semantic
|
|
745
|
+
static const success = Color(0xFF22C55E); // green-500
|
|
746
|
+
static const warning = Color(0xFFF59E0B); // amber-500
|
|
747
|
+
static const error = Color(0xFFEF4444); // red-500
|
|
748
|
+
static const info = Color(0xFF06B6D4); // cyan-500
|
|
749
|
+
}
|
|
750
|
+
```
|
|
751
|
+
|
|
752
|
+
### Typography
|
|
753
|
+
|
|
754
|
+
Only 3 Inter weights are available per `technical-preferences-ux.md`.
|
|
755
|
+
|
|
756
|
+
```dart
|
|
757
|
+
// shared/theme/app_typography.dart
|
|
758
|
+
import 'package:flutter/material.dart';
|
|
759
|
+
|
|
760
|
+
abstract final class AppTypography {
|
|
761
|
+
static const _fontFamily = 'Inter';
|
|
762
|
+
|
|
763
|
+
static const h1 = TextStyle(fontFamily: _fontFamily, fontSize: 36, fontWeight: FontWeight.w700, height: 1.2);
|
|
764
|
+
static const h2 = TextStyle(fontFamily: _fontFamily, fontSize: 30, fontWeight: FontWeight.w700, height: 1.2);
|
|
765
|
+
static const h3 = TextStyle(fontFamily: _fontFamily, fontSize: 24, fontWeight: FontWeight.w600, height: 1.3);
|
|
766
|
+
static const h4 = TextStyle(fontFamily: _fontFamily, fontSize: 20, fontWeight: FontWeight.w600, height: 1.3);
|
|
767
|
+
static const h5 = TextStyle(fontFamily: _fontFamily, fontSize: 18, fontWeight: FontWeight.w500, height: 1.4);
|
|
768
|
+
static const h6 = TextStyle(fontFamily: _fontFamily, fontSize: 16, fontWeight: FontWeight.w500, height: 1.4);
|
|
769
|
+
|
|
770
|
+
static const bodyLarge = TextStyle(fontFamily: _fontFamily, fontSize: 18, fontWeight: FontWeight.w400, height: 1.6);
|
|
771
|
+
static const body = TextStyle(fontFamily: _fontFamily, fontSize: 16, fontWeight: FontWeight.w400, height: 1.6);
|
|
772
|
+
static const bodySmall = TextStyle(fontFamily: _fontFamily, fontSize: 14, fontWeight: FontWeight.w400, height: 1.5);
|
|
773
|
+
static const caption = TextStyle(fontFamily: _fontFamily, fontSize: 12, fontWeight: FontWeight.w300, height: 1.4);
|
|
774
|
+
|
|
775
|
+
static const label = TextStyle(fontFamily: _fontFamily, fontSize: 14, fontWeight: FontWeight.w500, height: 1.4);
|
|
776
|
+
static const buttonLg = TextStyle(fontFamily: _fontFamily, fontSize: 16, fontWeight: FontWeight.w600, height: 1.0);
|
|
777
|
+
static const buttonSm = TextStyle(fontFamily: _fontFamily, fontSize: 14, fontWeight: FontWeight.w500, height: 1.0);
|
|
778
|
+
static const badge = TextStyle(fontFamily: _fontFamily, fontSize: 12, fontWeight: FontWeight.w700, height: 1.0);
|
|
779
|
+
}
|
|
780
|
+
```
|
|
781
|
+
|
|
782
|
+
### Theme
|
|
783
|
+
|
|
784
|
+
```dart
|
|
785
|
+
// shared/theme/app_theme.dart
|
|
786
|
+
import 'package:flutter/material.dart';
|
|
787
|
+
import 'app_colors.dart';
|
|
788
|
+
import 'app_typography.dart';
|
|
789
|
+
|
|
790
|
+
abstract final class AppTheme {
|
|
791
|
+
static ThemeData get light => ThemeData(
|
|
792
|
+
useMaterial3: true,
|
|
793
|
+
fontFamily: 'Inter',
|
|
794
|
+
colorScheme: ColorScheme.fromSeed(
|
|
795
|
+
seedColor: AppColors.primary500,
|
|
796
|
+
brightness: Brightness.light,
|
|
797
|
+
primary: AppColors.primary500,
|
|
798
|
+
secondary: AppColors.tertiary700,
|
|
799
|
+
error: AppColors.error,
|
|
800
|
+
surface: AppColors.surfaceLight,
|
|
801
|
+
),
|
|
802
|
+
scaffoldBackgroundColor: AppColors.backgroundLight,
|
|
803
|
+
appBarTheme: const AppBarTheme(
|
|
804
|
+
backgroundColor: AppColors.backgroundLight,
|
|
805
|
+
foregroundColor: Colors.black,
|
|
806
|
+
elevation: 0,
|
|
807
|
+
titleTextStyle: AppTypography.h5,
|
|
808
|
+
),
|
|
809
|
+
elevatedButtonTheme: ElevatedButtonThemeData(
|
|
810
|
+
style: ElevatedButton.styleFrom(
|
|
811
|
+
backgroundColor: AppColors.primary500,
|
|
812
|
+
foregroundColor: Colors.white,
|
|
813
|
+
textStyle: AppTypography.buttonLg,
|
|
814
|
+
minimumSize: const Size(0, 48),
|
|
815
|
+
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
|
816
|
+
),
|
|
817
|
+
),
|
|
818
|
+
inputDecorationTheme: InputDecorationTheme(
|
|
819
|
+
border: OutlineInputBorder(
|
|
820
|
+
borderRadius: BorderRadius.circular(8),
|
|
821
|
+
borderSide: const BorderSide(color: AppColors.borderLight),
|
|
822
|
+
),
|
|
823
|
+
labelStyle: AppTypography.label,
|
|
824
|
+
),
|
|
825
|
+
textTheme: const TextTheme(
|
|
826
|
+
displayLarge: AppTypography.h1,
|
|
827
|
+
displayMedium: AppTypography.h2,
|
|
828
|
+
displaySmall: AppTypography.h3,
|
|
829
|
+
headlineMedium: AppTypography.h4,
|
|
830
|
+
headlineSmall: AppTypography.h5,
|
|
831
|
+
titleLarge: AppTypography.h6,
|
|
832
|
+
bodyLarge: AppTypography.bodyLarge,
|
|
833
|
+
bodyMedium: AppTypography.body,
|
|
834
|
+
bodySmall: AppTypography.bodySmall,
|
|
835
|
+
labelLarge: AppTypography.buttonLg,
|
|
836
|
+
labelSmall: AppTypography.badge,
|
|
837
|
+
),
|
|
838
|
+
);
|
|
839
|
+
|
|
840
|
+
static ThemeData get dark => ThemeData(
|
|
841
|
+
useMaterial3: true,
|
|
842
|
+
fontFamily: 'Inter',
|
|
843
|
+
colorScheme: ColorScheme.fromSeed(
|
|
844
|
+
seedColor: AppColors.primary500,
|
|
845
|
+
brightness: Brightness.dark,
|
|
846
|
+
primary: AppColors.primary400,
|
|
847
|
+
secondary: AppColors.tertiary700,
|
|
848
|
+
error: AppColors.error,
|
|
849
|
+
surface: AppColors.surfaceDark,
|
|
850
|
+
),
|
|
851
|
+
scaffoldBackgroundColor: AppColors.backgroundDark,
|
|
852
|
+
);
|
|
853
|
+
}
|
|
854
|
+
```
|
|
855
|
+
|
|
856
|
+
---
|
|
857
|
+
|
|
858
|
+
## Local Database (Drift)
|
|
859
|
+
|
|
860
|
+
```dart
|
|
861
|
+
// core/storage/app_database.dart
|
|
862
|
+
import 'package:drift/drift.dart';
|
|
863
|
+
import 'package:drift/native.dart';
|
|
864
|
+
import 'package:injectable/injectable.dart';
|
|
865
|
+
import 'package:path_provider/path_provider.dart';
|
|
866
|
+
import 'package:path/path.dart' as p;
|
|
867
|
+
import 'dart:io';
|
|
868
|
+
|
|
869
|
+
part 'app_database.g.dart';
|
|
870
|
+
|
|
871
|
+
// Table definitions follow snake_case automatically
|
|
872
|
+
class CartItems extends Table {
|
|
873
|
+
TextColumn get id => text()();
|
|
874
|
+
TextColumn get productId => text()();
|
|
875
|
+
TextColumn get productName => text()();
|
|
876
|
+
IntColumn get quantity => integer()();
|
|
877
|
+
RealColumn get unitPrice => real()();
|
|
878
|
+
|
|
879
|
+
@override
|
|
880
|
+
Set<Column> get primaryKey => {id};
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
@DriftDatabase(tables: [CartItems])
|
|
884
|
+
@singleton
|
|
885
|
+
class AppDatabase extends _$AppDatabase {
|
|
886
|
+
AppDatabase() : super(_openConnection());
|
|
887
|
+
|
|
888
|
+
@override
|
|
889
|
+
int get schemaVersion => 1;
|
|
890
|
+
|
|
891
|
+
Future<List<CartItemData>> getAllCartItems() =>
|
|
892
|
+
select(cartItems).get();
|
|
893
|
+
|
|
894
|
+
Stream<List<CartItemData>> watchAllCartItems() =>
|
|
895
|
+
select(cartItems).watch();
|
|
896
|
+
|
|
897
|
+
Future<int> upsertCartItem(CartItemsCompanion item) =>
|
|
898
|
+
into(cartItems).insertOnConflictUpdate(item);
|
|
899
|
+
|
|
900
|
+
Future<int> deleteCartItem(String id) =>
|
|
901
|
+
(delete(cartItems)..where((t) => t.id.equals(id))).go();
|
|
902
|
+
|
|
903
|
+
Future<int> clearAllCartItems() =>
|
|
904
|
+
delete(cartItems).go();
|
|
905
|
+
|
|
906
|
+
static QueryExecutor _openConnection() {
|
|
907
|
+
return LazyDatabase(() async {
|
|
908
|
+
final dir = await getApplicationDocumentsDirectory();
|
|
909
|
+
final file = File(p.join(dir.path, 'app.db'));
|
|
910
|
+
return NativeDatabase.createInBackground(file);
|
|
911
|
+
});
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
```
|
|
915
|
+
|
|
916
|
+
---
|
|
917
|
+
|
|
918
|
+
## Dependency Injection
|
|
919
|
+
|
|
920
|
+
```dart
|
|
921
|
+
// app/di/injection.dart
|
|
922
|
+
import 'package:get_it/get_it.dart';
|
|
923
|
+
import 'package:injectable/injectable.dart';
|
|
924
|
+
import 'injection.config.dart';
|
|
925
|
+
|
|
926
|
+
final getIt = GetIt.instance;
|
|
927
|
+
|
|
928
|
+
@InjectableInit(
|
|
929
|
+
initializerName: 'init',
|
|
930
|
+
preferRelativeImports: true,
|
|
931
|
+
asExtension: true,
|
|
932
|
+
)
|
|
933
|
+
Future<void> configureDependencies() async => getIt.init();
|
|
934
|
+
```
|
|
935
|
+
|
|
936
|
+
Annotate classes with `@injectable`, `@singleton`, `@lazySingleton`, `@factoryMethod` as appropriate. Run `dart run build_runner build --delete-conflicting-outputs` to regenerate.
|
|
937
|
+
|
|
938
|
+
---
|
|
939
|
+
|
|
940
|
+
## Testing Standards
|
|
941
|
+
|
|
942
|
+
### Strategy
|
|
943
|
+
|
|
944
|
+
| Type | Tool | Coverage Target | What to Test |
|
|
945
|
+
|------|------|-----------------|--------------|
|
|
946
|
+
| **Unit** | `flutter_test` + `mocktail` | > 80% | Entities, value objects, use cases |
|
|
947
|
+
| **Widget** | `flutter_test` | Medium | Key widgets and user interactions |
|
|
948
|
+
| **Integration** | `patrol` | Low | Critical user journeys end-to-end |
|
|
949
|
+
|
|
950
|
+
### Unit Test Example
|
|
951
|
+
|
|
952
|
+
```dart
|
|
953
|
+
// test/modules/sales/quotes/cart/application/use_cases/add_to_cart_test.dart
|
|
954
|
+
import 'package:dartz/dartz.dart';
|
|
955
|
+
import 'package:flutter_test/flutter_test.dart';
|
|
956
|
+
import 'package:mocktail/mocktail.dart';
|
|
957
|
+
|
|
958
|
+
class MockCartRepository extends Mock implements ICartRepository {}
|
|
959
|
+
|
|
960
|
+
void main() {
|
|
961
|
+
late MockCartRepository mockRepository;
|
|
962
|
+
late AddToCart useCase;
|
|
963
|
+
|
|
964
|
+
setUp(() {
|
|
965
|
+
mockRepository = MockCartRepository();
|
|
966
|
+
useCase = AddToCart(mockRepository);
|
|
967
|
+
});
|
|
968
|
+
|
|
969
|
+
group('AddToCart', () {
|
|
970
|
+
test('returns CartItem when quantity is valid', () async {
|
|
971
|
+
const productId = 'prod-1';
|
|
972
|
+
const quantity = 2;
|
|
973
|
+
final item = CartItem(
|
|
974
|
+
id: 'item-1',
|
|
975
|
+
productId: productId,
|
|
976
|
+
productName: 'Test Product',
|
|
977
|
+
quantity: quantity,
|
|
978
|
+
unitPrice: 10.0,
|
|
979
|
+
);
|
|
980
|
+
|
|
981
|
+
when(() => mockRepository.addItem(
|
|
982
|
+
productId: productId,
|
|
983
|
+
quantity: quantity,
|
|
984
|
+
)).thenAnswer((_) async => right(item));
|
|
985
|
+
|
|
986
|
+
final result = await useCase(
|
|
987
|
+
productId: productId,
|
|
988
|
+
quantity: quantity,
|
|
989
|
+
);
|
|
990
|
+
|
|
991
|
+
expect(result, right(item));
|
|
992
|
+
verify(() => mockRepository.addItem(
|
|
993
|
+
productId: productId,
|
|
994
|
+
quantity: quantity,
|
|
995
|
+
)).called(1);
|
|
996
|
+
});
|
|
997
|
+
|
|
998
|
+
test('returns InvalidQuantity failure when quantity is zero', () async {
|
|
999
|
+
final result = await useCase(productId: 'prod-1', quantity: 0);
|
|
1000
|
+
|
|
1001
|
+
expect(result, left(const InvalidQuantity()));
|
|
1002
|
+
verifyNever(() => mockRepository.addItem(
|
|
1003
|
+
productId: any(named: 'productId'),
|
|
1004
|
+
quantity: any(named: 'quantity'),
|
|
1005
|
+
));
|
|
1006
|
+
});
|
|
1007
|
+
});
|
|
1008
|
+
}
|
|
1009
|
+
```
|
|
1010
|
+
|
|
1011
|
+
### Widget Test Example
|
|
1012
|
+
|
|
1013
|
+
```dart
|
|
1014
|
+
// test/modules/sales/quotes/cart/presentation/widgets/cart_item_widget_test.dart
|
|
1015
|
+
import 'package:flutter/material.dart';
|
|
1016
|
+
import 'package:flutter_test/flutter_test.dart';
|
|
1017
|
+
|
|
1018
|
+
void main() {
|
|
1019
|
+
group('CartItemWidget', () {
|
|
1020
|
+
testWidgets('displays product name and total price', (tester) async {
|
|
1021
|
+
const item = CartItem(
|
|
1022
|
+
id: '1',
|
|
1023
|
+
productId: 'p1',
|
|
1024
|
+
productName: 'Producto Test',
|
|
1025
|
+
quantity: 2,
|
|
1026
|
+
unitPrice: 50.0,
|
|
1027
|
+
);
|
|
1028
|
+
|
|
1029
|
+
var removedId = '';
|
|
1030
|
+
|
|
1031
|
+
await tester.pumpWidget(
|
|
1032
|
+
MaterialApp(
|
|
1033
|
+
home: Scaffold(
|
|
1034
|
+
body: CartItemWidget(
|
|
1035
|
+
item: item,
|
|
1036
|
+
onRemove: (id) => removedId = id,
|
|
1037
|
+
),
|
|
1038
|
+
),
|
|
1039
|
+
),
|
|
1040
|
+
);
|
|
1041
|
+
|
|
1042
|
+
expect(find.text('Producto Test'), findsOneWidget);
|
|
1043
|
+
expect(find.text('\$100.00'), findsOneWidget);
|
|
1044
|
+
|
|
1045
|
+
await tester.tap(find.byIcon(Icons.delete_outline));
|
|
1046
|
+
await tester.pump();
|
|
1047
|
+
|
|
1048
|
+
expect(removedId, '1');
|
|
1049
|
+
});
|
|
1050
|
+
});
|
|
1051
|
+
}
|
|
1052
|
+
```
|
|
1053
|
+
|
|
1054
|
+
---
|
|
1055
|
+
|
|
1056
|
+
## Error Handling
|
|
1057
|
+
|
|
1058
|
+
### Global Errors
|
|
1059
|
+
|
|
1060
|
+
Register a `FlutterError.onError` and `PlatformDispatcher.instance.onError` handler in `main.dart`:
|
|
1061
|
+
|
|
1062
|
+
```dart
|
|
1063
|
+
// main.dart
|
|
1064
|
+
Future<void> main() async {
|
|
1065
|
+
WidgetsFlutterBinding.ensureInitialized();
|
|
1066
|
+
|
|
1067
|
+
FlutterError.onError = (details) {
|
|
1068
|
+
// Log to Crashlytics / Sentry
|
|
1069
|
+
FirebaseCrashlytics.instance.recordFlutterFatalError(details);
|
|
1070
|
+
};
|
|
1071
|
+
|
|
1072
|
+
PlatformDispatcher.instance.onError = (error, stack) {
|
|
1073
|
+
FirebaseCrashlytics.instance.recordError(error, stack, fatal: true);
|
|
1074
|
+
return true;
|
|
1075
|
+
};
|
|
1076
|
+
|
|
1077
|
+
await configureDependencies();
|
|
1078
|
+
runApp(
|
|
1079
|
+
ProviderScope(
|
|
1080
|
+
child: const App(),
|
|
1081
|
+
),
|
|
1082
|
+
);
|
|
1083
|
+
}
|
|
1084
|
+
```
|
|
1085
|
+
|
|
1086
|
+
### UI-Level Error Handling
|
|
1087
|
+
|
|
1088
|
+
Use `AsyncValue`'s pattern matching (as shown in the page example above). Never expose raw exception messages to the UI. Map domain failures to user-friendly Spanish strings in the notifier.
|
|
1089
|
+
|
|
1090
|
+
---
|
|
1091
|
+
|
|
1092
|
+
## Security Standards
|
|
1093
|
+
|
|
1094
|
+
| Concern | Solution |
|
|
1095
|
+
|---------|---------|
|
|
1096
|
+
| Token storage | `flutter_secure_storage` (Keychain/Keystore) |
|
|
1097
|
+
| Certificate pinning | `dio_certificate_pinner` |
|
|
1098
|
+
| Screenshot prevention | `FlutterWindowManager.addFlags` (Android) |
|
|
1099
|
+
| Root/jailbreak detection | `flutter_jailbreak_detection` |
|
|
1100
|
+
| Sensitive data in logs | Redact with `LoggingInterceptor` |
|
|
1101
|
+
| Obfuscation | `flutter build --obfuscate --split-debug-info` |
|
|
1102
|
+
|
|
1103
|
+
---
|
|
1104
|
+
|
|
1105
|
+
## Performance Standards
|
|
1106
|
+
|
|
1107
|
+
| Strategy | Implementation |
|
|
1108
|
+
|----------|---------------|
|
|
1109
|
+
| Const widgets | Mark all stateless leaf widgets `const` |
|
|
1110
|
+
| List virtualization | Always use `ListView.builder`, never `Column` for dynamic lists |
|
|
1111
|
+
| Image caching | `cached_network_image` with disk cache |
|
|
1112
|
+
| State granularity | Use `select()` on providers to prevent unnecessary rebuilds |
|
|
1113
|
+
| Build profiling | `flutter run --profile` + DevTools |
|
|
1114
|
+
| Isolates | Offload heavy computation with `compute()` |
|
|
1115
|
+
| App startup | Defer non-critical initialization after first frame |
|
|
1116
|
+
|
|
1117
|
+
---
|
|
1118
|
+
|
|
1119
|
+
## Localization
|
|
1120
|
+
|
|
1121
|
+
All user-visible text must be in Spanish. Code, logs, comments, and git commits stay in English (same rule as frontend and backend standards).
|
|
1122
|
+
|
|
1123
|
+
```yaml
|
|
1124
|
+
# pubspec.yaml
|
|
1125
|
+
flutter:
|
|
1126
|
+
generate: true
|
|
1127
|
+
|
|
1128
|
+
# l10n.yaml
|
|
1129
|
+
arb-dir: lib/l10n
|
|
1130
|
+
template-arb-file: app_en.arb
|
|
1131
|
+
output-localization-file: app_localizations.dart
|
|
1132
|
+
```
|
|
1133
|
+
|
|
1134
|
+
```json
|
|
1135
|
+
// lib/l10n/app_es.arb
|
|
1136
|
+
{
|
|
1137
|
+
"@@locale": "es",
|
|
1138
|
+
"cartTitle": "Carrito",
|
|
1139
|
+
"cartEmpty": "Tu carrito está vacío",
|
|
1140
|
+
"addToCart": "Agregar al carrito",
|
|
1141
|
+
"removeFromCart": "Eliminar del carrito",
|
|
1142
|
+
"checkout": "Finalizar compra",
|
|
1143
|
+
"errorGeneric": "Ha ocurrido un error. Intenta de nuevo.",
|
|
1144
|
+
"errorNotFound": "El recurso solicitado no fue encontrado",
|
|
1145
|
+
"loading": "Cargando..."
|
|
1146
|
+
}
|
|
1147
|
+
```
|
|
1148
|
+
|
|
1149
|
+
---
|
|
1150
|
+
|
|
1151
|
+
## Code Generation
|
|
1152
|
+
|
|
1153
|
+
Run after every change to annotated classes:
|
|
1154
|
+
|
|
1155
|
+
```bash
|
|
1156
|
+
dart run build_runner build --delete-conflicting-outputs
|
|
1157
|
+
```
|
|
1158
|
+
|
|
1159
|
+
Files generated automatically (never edit manually):
|
|
1160
|
+
|
|
1161
|
+
| Generator | Output |
|
|
1162
|
+
|-----------|--------|
|
|
1163
|
+
| `freezed` | `*.freezed.dart` |
|
|
1164
|
+
| `json_serializable` | `*.g.dart` (JSON) |
|
|
1165
|
+
| `riverpod_generator` | `*.g.dart` (providers) |
|
|
1166
|
+
| `injectable_generator` | `injection.config.dart` |
|
|
1167
|
+
| `drift_dev` | `*.g.dart` (DB) |
|
|
1168
|
+
| `flutter_gen` | `assets.gen.dart` |
|
|
1169
|
+
|
|
1170
|
+
Add all `*.g.dart` and `*.freezed.dart` to `.gitignore` only if the project enforces generation in CI. Otherwise commit them for faster cold builds.
|
|
1171
|
+
|
|
1172
|
+
---
|
|
1173
|
+
|
|
1174
|
+
## pubspec.yaml Reference
|
|
1175
|
+
|
|
1176
|
+
```yaml
|
|
1177
|
+
name: your_app
|
|
1178
|
+
description: Enterprise Flutter app
|
|
1179
|
+
|
|
1180
|
+
environment:
|
|
1181
|
+
sdk: '>=3.6.0 <4.0.0'
|
|
1182
|
+
flutter: '>=3.27.0'
|
|
1183
|
+
|
|
1184
|
+
dependencies:
|
|
1185
|
+
flutter:
|
|
1186
|
+
sdk: flutter
|
|
1187
|
+
flutter_localizations:
|
|
1188
|
+
sdk: flutter
|
|
1189
|
+
|
|
1190
|
+
# Architecture & DI
|
|
1191
|
+
get_it: ^8.0.0
|
|
1192
|
+
injectable: ^2.5.0
|
|
1193
|
+
dartz: ^0.10.1
|
|
1194
|
+
|
|
1195
|
+
# State management
|
|
1196
|
+
flutter_riverpod: ^2.6.0
|
|
1197
|
+
riverpod_annotation: ^2.4.0
|
|
1198
|
+
|
|
1199
|
+
# Navigation
|
|
1200
|
+
go_router: ^14.0.0
|
|
1201
|
+
|
|
1202
|
+
# Network
|
|
1203
|
+
dio: ^5.7.0
|
|
1204
|
+
connectivity_plus: ^6.0.0
|
|
1205
|
+
|
|
1206
|
+
# Local storage
|
|
1207
|
+
drift: ^2.23.0
|
|
1208
|
+
sqlite3_flutter_libs: ^0.5.0
|
|
1209
|
+
flutter_secure_storage: ^9.2.0
|
|
1210
|
+
shared_preferences: ^2.3.0
|
|
1211
|
+
|
|
1212
|
+
# Models
|
|
1213
|
+
freezed_annotation: ^2.4.0
|
|
1214
|
+
json_annotation: ^4.9.0
|
|
1215
|
+
|
|
1216
|
+
# Validation
|
|
1217
|
+
formz: ^0.7.0
|
|
1218
|
+
|
|
1219
|
+
# UI
|
|
1220
|
+
cached_network_image: ^3.4.0
|
|
1221
|
+
|
|
1222
|
+
dev_dependencies:
|
|
1223
|
+
flutter_test:
|
|
1224
|
+
sdk: flutter
|
|
1225
|
+
build_runner: ^2.4.0
|
|
1226
|
+
freezed: ^2.5.0
|
|
1227
|
+
json_serializable: ^6.8.0
|
|
1228
|
+
riverpod_generator: ^2.4.0
|
|
1229
|
+
injectable_generator: ^2.6.0
|
|
1230
|
+
drift_dev: ^2.23.0
|
|
1231
|
+
flutter_gen_runner: ^5.7.0
|
|
1232
|
+
mocktail: ^1.0.4
|
|
1233
|
+
patrol: ^3.13.0
|
|
1234
|
+
very_good_analysis: ^6.0.0
|
|
1235
|
+
|
|
1236
|
+
flutter:
|
|
1237
|
+
uses-material-design: true
|
|
1238
|
+
generate: true
|
|
1239
|
+
fonts:
|
|
1240
|
+
- family: Inter
|
|
1241
|
+
fonts:
|
|
1242
|
+
- asset: assets/fonts/Inter_18pt-Light.ttf
|
|
1243
|
+
weight: 300
|
|
1244
|
+
- asset: assets/fonts/Inter_18pt-Regular.ttf
|
|
1245
|
+
weight: 400
|
|
1246
|
+
- asset: assets/fonts/Inter_18pt-Bold.ttf
|
|
1247
|
+
weight: 700
|
|
1248
|
+
assets:
|
|
1249
|
+
- assets/fonts/
|
|
1250
|
+
- assets/images/
|
|
1251
|
+
- assets/images/logos/
|
|
1252
|
+
```
|
|
1253
|
+
|
|
1254
|
+
---
|
|
1255
|
+
|
|
1256
|
+
## Implementation Checklist
|
|
1257
|
+
|
|
1258
|
+
### Project Setup
|
|
1259
|
+
- [ ] Flutter 3.27+ stable channel
|
|
1260
|
+
- [ ] `very_good_analysis` lint rules configured
|
|
1261
|
+
- [ ] `l10n.yaml` and `.arb` files created
|
|
1262
|
+
- [ ] `get_it` + `injectable` wired in `main.dart`
|
|
1263
|
+
- [ ] `ProviderScope` wrapping the entire app
|
|
1264
|
+
- [ ] GoRouter with auth redirect guard
|
|
1265
|
+
- [ ] `AppTheme.light` and `AppTheme.dark` applied
|
|
1266
|
+
- [ ] Inter font family assets loaded
|
|
1267
|
+
- [ ] Brand colors in `AppColors` matching `technical-preferences-ux.md`
|
|
1268
|
+
- [ ] `build_runner` script added to `Makefile` or `justfile`
|
|
1269
|
+
|
|
1270
|
+
### Per Feature
|
|
1271
|
+
- [ ] Domain entities with `Freezed`
|
|
1272
|
+
- [ ] Value objects with `Either` return
|
|
1273
|
+
- [ ] Sealed failure class
|
|
1274
|
+
- [ ] Repository interface in domain
|
|
1275
|
+
- [ ] Use case(s) with `@injectable`
|
|
1276
|
+
- [ ] `AsyncNotifier` provider with `@riverpod`
|
|
1277
|
+
- [ ] Infrastructure model with `fromDomain` / `toDomain`
|
|
1278
|
+
- [ ] Remote datasource using `DioClient`
|
|
1279
|
+
- [ ] Local datasource using `Drift` or `SecureStorage`
|
|
1280
|
+
- [ ] Repository implementation with `@LazySingleton(as: IRepository)`
|
|
1281
|
+
- [ ] Page using `ConsumerWidget`
|
|
1282
|
+
- [ ] Widgets separated from pages
|
|
1283
|
+
- [ ] Unit tests for use cases and value objects
|
|
1284
|
+
- [ ] Widget tests for key interactions
|
|
1285
|
+
|
|
1286
|
+
### Quality
|
|
1287
|
+
- [ ] `flutter analyze` returns zero warnings
|
|
1288
|
+
- [ ] All user-visible strings in Spanish `.arb` files
|
|
1289
|
+
- [ ] No hardcoded colors or text sizes (use theme tokens)
|
|
1290
|
+
- [ ] `const` constructors on all leaf widgets
|
|
1291
|
+
- [ ] `ListView.builder` for all dynamic lists
|
|
1292
|
+
- [ ] Sensitive data only in `flutter_secure_storage`
|