Modèles Dart/Flutter
Quand utiliser
Utilisez ce skill quand :
- Vous démarrez une nouvelle fonctionnalité Flutter et avez besoin de modèles idiomatiques pour la gestion d'état, la navigation ou l'accès aux données
- Vous révisez ou écrivez du code Dart et avez besoin de conseils sur la null safety, les types sealed ou la composition asynchrone
- Vous configurez un nouveau projet Flutter et choisissez entre BLoC, Riverpod ou Provider
- Vous implémentez des clients HTTP sécurisés, une intégration WebView ou du stockage local
- Vous écrivez des tests pour des widgets Flutter, des Cubits ou des providers Riverpod
- Vous intégrez GoRouter avec des gardes d'authentification
Comment ça marche
Ce skill fournit des modèles de code Dart/Fluffer prêts à copier-coller, organisés par préoccupation :
- Null safety — éviter
!, préférer?./??/le pattern matching - État immuable — classes sealed,
freezed,copyWith - Composition asynchrone —
Future.waitconcurrent,BuildContextsûr aprèsawait - Architecture des widgets — extraction dans des classes (pas des méthodes), propagation
const, reconstructions circonscrites - Gestion d'état — événements BLoC/Cubit, notificateurs et providers dérivés Riverpod
- Navigation — GoRouter avec gardes d'authentification réactives via
refreshListenable - Réseau — Dio avec intercepteurs, rafraîchissement de token avec garde de tentative unique
- Gestion des erreurs — capture globale,
ErrorWidget.builder, intégration Crashlytics - Tests — unitaires (test BLoC), widgets (surcharges ProviderScope), fakes plutôt que mocks
Exemples
// État sealed — empêche les états impossibles
sealed class AsyncState<T> {}
final class Loading<T> extends AsyncState<T> {}
final class Success<T> extends AsyncState<T> { final T data; const Success(this.data); }
final class Failure<T> extends AsyncState<T> { final Object error; const Failure(this.error); }
// GoRouter avec redirection réactive
final router = GoRouter(
refreshListenable: GoRouterRefreshStream(authCubit.stream),
redirect: (context, state) {
final authed = context.read<AuthCubit>().state is AuthAuthenticated;
if (!authed && !state.matchedLocation.startsWith('/login')) return '/login';
return null;
},
routes: [...],
);
// Provider dérivé Riverpod avec firstWhereOrNull sûr
@riverpod
double cartTotal(Ref ref) {
final cart = ref.watch(cartNotifierProvider);
final products = ref.watch(productsProvider).valueOrNull ?? [];
return cart.fold(0.0, (total, item) {
final product = products.firstWhereOrNull((p) => p.id == item.productId);
return total + (product?.price ?? 0) * item.quantity;
});
}
Modèles pratiques et prêts pour la production pour les applications Dart et Flutter. Aussi agnostiques que possible en termes de bibliothèques, avec une couverture explicite des packages écosystémiques les plus courants.
1. Fondamentaux de la Null Safety
Privilégier les motifs à l'opérateur bang
// MAUVAIS — plante à l'exécution si null
final name = user!.name;
// BON — fournir une valeur de repli
final name = user?.name ?? 'Unknown';
// BON — Pattern matching Dart 3 (préféré pour les cas complexes)
final display = switch (user) {
User(:final name, :final email) => '$name <$email>',
null => 'Invité',
};
// BON — retour anticipé avec garde
String getUserName(User? user) {
if (user == null) return 'Unknown';
return user.name; // promu en non-null après la vérification
}
Éviter l'utilisation excessive de late
// MAUVAIS — reporte l'erreur null à l'exécution
late String userId;
// BON — nullable avec initialisation explicite
String? userId;
// OK — utiliser late uniquement quand l'initialisation est garantie avant le premier accès
// (par exemple, dans initState() avant toute interaction avec le widget)
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(vsync: this, duration: const Duration(milliseconds: 300));
}
2. État immuable
Classes sealed pour les hiérarchies d'états
sealed class UserState {}
final class UserInitial extends UserState {}
final class UserLoading extends UserState {}
final class UserLoaded extends UserState {
const UserLoaded(this.user);
final User user;
}
final class UserError extends UserState {
const UserError(this.message);
final String message;
}
// Switch exhaustif — le compilateur force toutes les branches
Widget buildFrom(UserState state) => switch (state) {
UserInitial() => const SizedBox.shrink(),
UserLoading() => const CircularProgressIndicator(),
UserLoaded(:final user) => UserCard(user: user),
UserError(:final message) => ErrorText(message),
};
Freezed pour une immutabilité sans code boilerplate
import 'package:freezed_annotation/freezed_annotation.dart';
part 'user.freezed.dart';
part 'user.g.dart';
@freezed
class User with _$User {
const factory User({
required String id,
required String name,
required String email,
@Default(false) bool isAdmin,
}) = _User;
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}
// Utilisation
final user = User(id: '1', name: 'Alice', email: '[email protected]');
final updated = user.copyWith(name: 'Alice Smith'); // mise à jour immuable
final json = user.toJson();
final fromJson = User.fromJson(json);
3. Composition asynchrone
Concurrence structurée avec Future.wait
Future<DashboardData> loadDashboard(UserRepository users, OrderRepository orders) async {
// Exécuter en concurrence — ne pas attendre séquentiellement
final (userList, orderList) = await (
users.getAll(),
orders.getRecent(),
).wait; // Destructuration d'enregistrement Dart 3 + extension Future.wait
return DashboardData(users: userList, orders: orderList);
}
Motifs de Stream
// Le repository expose des streams réactifs pour les données en direct
Stream<List<Item>> watchCartItems() => _db
.watchTable('cart_items')
.map((rows) => rows.map(Item.fromRow).toList());
// Dans la couche widget — déclaratif, pas d'abonnement manuel
StreamBuilder<List<Item>>(
stream: cartRepository.watchCartItems(),
builder: (context, snapshot) => switch (snapshot) {
AsyncSnapshot(connectionState: ConnectionState.waiting) =>
const CircularProgressIndicator(),
AsyncSnapshot(:final error?) => ErrorWidget(error.toString()),
AsyncSnapshot(:final data?) => CartList(items: data),
_ => const SizedBox.shrink(),
},
)
BuildContext après await
// CRITIQUE — toujours vérifier mounted après tout await dans un StatefulWidget
Future<void> _handleSubmit() async {
setState(() => _isLoading = true);
try {
await authService.login(_email, _password);
if (!mounted) return; // ← garde avant d'utiliser context
context.go('/home');
} on AuthException catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.message)));
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
4. Architecture des widgets
Extraire dans des classes, pas des méthodes
// MAUVAIS — méthode privée retournant un widget, empêche l'optimisation
Widget _buildHeader() {
return Container(
padding: const EdgeInsets.all(16),
child: Text(title, style: Theme.of(context).textTheme.headlineMedium),
);
}
// BON — classe widget séparée, permet const, réutilisation d'élément
class _PageHeader extends StatelessWidget {
const _PageHeader(this.title);
final String title;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
child: Text(title, style: Theme.of(context).textTheme.headlineMedium),
);
}
}
Propagation de const
// MAUVAIS — nouvelles instances à chaque reconstruction
child: Padding(
padding: EdgeInsets.all(16.0), // pas const
child: Icon(Icons.home, size: 24.0), // pas const
)
// BON — const stoppe la propagation de la reconstruction
child: const Padding(
padding: EdgeInsets.all(16.0),
child: Icon(Icons.home, size: 24.0),
)
Reconstructions circonscrites
// MAUVAIS — toute la page se reconstruit à chaque changement de compteur
class CounterPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final count = ref.watch(counterProvider); // reconstruit tout
return Scaffold(
body: Column(children: [
const ExpensiveHeader(), // reconstruit inutilement
Text('$count'),
const ExpensiveFooter(), // reconstruit inutilement
]),
);
}
}
// BON — isoler la partie qui se reconstruit
class CounterPage extends StatelessWidget {
const CounterPage({super.key});
@override
Widget build(BuildContext context) {
return const Scaffold(
body: Column(children: [
ExpensiveHeader(), // jamais reconstruit (const)
_CounterDisplay(), // seulement celui-ci se reconstruit
ExpensiveFooter(), // jamais reconstruit (const)
]),
);
}
}
class _CounterDisplay extends ConsumerWidget {
const _CounterDisplay();
@override
Widget build(BuildContext context, WidgetRef ref) {
final count = ref.watch(counterProvider);
return Text('$count');
}
}
5. Gestion d'état : BLoC/Cubit
// Cubit — état synchrone ou asynchrone simple
class AuthCubit extends Cubit<AuthState> {
AuthCubit(this._authService) : super(const AuthState.initial());
final AuthService _authService;
Future<void> login(String email, String password) async {
emit(const AuthState.loading());
try {
final user = await _authService.login(email, password);
emit(AuthState.authenticated(user));
} on AuthException catch (e) {
emit(AuthState.error(e.message));
}
}
void logout() {
_authService.logout();
emit(const AuthState.initial());
}
}
// Dans le widget
BlocBuilder<AuthCubit, AuthState>(
builder: (context, state) => switch (state) {
AuthInitial() => const LoginForm(),
AuthLoading() => const CircularProgressIndicator(),
AuthAuthenticated(:final user) => HomePage(user: user),
AuthError(:final message) => ErrorView(message: message),
},
)
6. Gestion d'état : Riverpod
// Provider asynchrone à auto-disposition
@riverpod
Future<List<Product>> products(Ref ref) async {
final repo = ref.watch(productRepositoryProvider);
return repo.getAll();
}
// Notificateur avec mutations complexes
@riverpod
class CartNotifier extends _$CartNotifier {
@override
List<CartItem> build() => [];
void add(Product product) {
final existing = state.where((i) => i.productId == product.id).firstOrNull;
if (existing != null) {
state = [
for (final item in state)
if (item.productId == product.id) item.copyWith(quantity: item.quantity + 1)
else item,
];
} else {
state = [...state, CartItem(productId: product.id, quantity: 1)];
}
}
void remove(String productId) =>
state = state.where((i) => i.productId != productId).toList();
void clear() => state = [];
}
// Provider dérivé (motif sélecteur)
@riverpod
int cartCount(Ref ref) => ref.watch(cartNotifierProvider).length;
@riverpod
double cartTotal(Ref ref) {
final cart = ref.watch(cartNotifierProvider);
final products = ref.watch(productsProvider).valueOrNull ?? [];
return cart.fold(0.0, (total, item) {
// firstWhereOrNull (du package collection) évite StateError quand le produit est manquant
final product = products.firstWhereOrNull((p) => p.id == item.productId);
return total + (product?.price ?? 0) * item.quantity;
});
}
7. Navigation avec GoRouter
final router = GoRouter(
initialLocation: '/',
// refreshListenable réévalue la redirection à chaque changement d'état d'authentification
refreshListenable: GoRouterRefreshStream(authCubit.stream),
redirect: (context, state) {
final isLoggedIn = context.read<AuthCubit>().state is AuthAuthenticated;
final isGoingToLogin = state.matchedLocation == '/login';
if (!isLoggedIn && !isGoingToLogin) return '/login';
if (isLoggedIn && isGoingToLogin) return '/';
return null;
},
routes: [
GoRoute(path: '/login', builder: (_, __) => const LoginPage()),
ShellRoute(
builder: (context, state, child) => AppShell(child: child),
routes: [
GoRoute(path: '/', builder: (_, __) => const HomePage()),
GoRoute(
path: '/products/:id',
builder: (context, state) =>
ProductDetailPage(id: state.pathParameters['id']!),
),
],
),
],
);
8. HTTP avec Dio
final dio = Dio(BaseOptions(
baseUrl: const String.fromEnvironment('API_URL'),
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 30),
headers: {'Content-Type': 'application/json'},
));
// Ajouter un intercepteur d'authentification
dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) async {
final token = await secureStorage.read(key: 'auth_token');
if (token != null) options.headers['Authorization'] = 'Bearer $token';
handler.next(options);
},
onError: (error, handler) async {
// Protection contre les boucles de tentatives infinies : ne tenter le rafraîchissement qu'une fois par requête
final isRetry = error.requestOptions.extra['_isRetry'] == true;
if (!isRetry && error.response?.statusCode == 401) {
final refreshed = await attemptTokenRefresh();
if (refreshed) {
error.requestOptions.extra['_isRetry'] = true;
return handler.resolve(await dio.fetch(error.requestOptions));
}
}
handler.next(error);
},
));
// Repository utilisant Dio
class UserApiDataSource {
const UserApiDataSource(this._dio);
final Dio _dio;
Future<User> getById(String id) async {
final response = await _dio.get<Map<String, dynamic>>('/users/$id');
return User.fromJson(response.data!);
}
}
9. Architecture de gestion des erreurs
// Capture globale des erreurs — configurer dans main()
void main() {
FlutterError.onError = (details) {
FlutterError.presentError(details);
crashlytics.recordFlutterFatalError(details);
};
PlatformDispatcher.instance.onError = (error, stack) {
crashlytics.recordError(error, stack, fatal: true);
return true;
};
runApp(const App());
}
// ErrorWidget personnalisé pour la production
class App extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
builder: (context, widget) {
ErrorWidget.builder = (details) => const Scaffold(
body: Center(
child: Text('Une erreur inattendue s'est produite. Veuillez réessayer.'),
),
);
return widget!;
},
home: const HomePage(),
);
}
}
// Extension sécurisée pour les conversions nullables
extension SafeCast<T> on T? {
R? let<R>(R Function(T) f) => switch (this) {
final T value => f(value),
null => null,
};
}
}
10. Tests
// Tests BLoC/Cubit
void main() {
group('AuthCubit', () {
late AuthCubit cubit;
late MockAuthService mockAuthService;
setUp(() {
mockAuthService = MockAuthService();
cubit = AuthCubit(mockAuthService);
});
test('initial state is AuthInitial', () {
expect(cubit.state, const AuthState.initial());
});
blocTest<AuthCubit, AuthState>(
'emits [loading, authenticated] when login succeeds',
build: () => cubit,
act: (cubit) => cubit.login('email', 'password'),
expect: () => [
const AuthState.loading(),
AuthState.authenticated(User(id: '1', name: 'Alice')),
],
);
blocTest<AuthCubit, AuthState>(
'emits [loading, error] when login fails',
build: () => cubit,
act: (cubit) => cubit.login('email', 'wrong'),
expect: () => [
const AuthState.loading(),
const AuthState.error('Invalid credentials'),
],
);
});
}
// Tests de widgets avec ProviderScope
void main() {
testWidgets('CounterPage displays count', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
counterProvider.overrideWithValue(42),
],
child: const MaterialApp(home: CounterPage()),
),
);
expect(find.text('42'), findsOneWidget);
});
}
// Fakes plutôt que mocks — meilleure maintenabilité
class FakeAuthService implements AuthService {
FakeAuthService({this.shouldSucceed = true, this.fakeUser = const User(id: '1', name: 'Alice')});
final bool shouldSucceed;
final User fakeUser;
@override
Future<User> login(String email, String password) async {
if (!shouldSucceed) throw const AuthException('Invalid credentials');
return fakeUser;
}
}
Modèles pratiques et prêts pour la production pour les applications Dart et Flutter. Aussi agnostiques que possible en termes de bibliothèques, avec une couverture explicite des packages écosystémiques les plus courants.