flutter-apply-architecture-best-practices

--- Architecturez une application Flutter en utilisant l'approche en couches recommandée (UI, Logic, Data). À utiliser lors de la structuration d'un nouveau projet ou d'une refactorisation pour la scalabilité.

npx skills add https://github.com/flutter/skills --skill flutter-apply-architecture-best-practices

Architecture des Applications Flutter

Sommaire

Couches Architecturales

Appliquez une stricte Séparation des Préoccupations en divisant l'application en couches distinctes. Ne mélangez jamais le rendu UI avec la logique métier ou la récupération de données.

Couche UI (Présentation)

Implémentez le modèle MVVM (Model-View-ViewModel) pour gérer l'état et la logique de l'UI.

  • Views : Écrivez des widgets réutilisables et épurés. Restreignez la logique dans les Views aux opérations spécifiques à l'UI (par exemple, animations, contraintes de mise en page, routage simple). Transmettez toutes les données requises depuis le ViewModel.
  • ViewModels : Gérez l'état de l'UI et traitez les interactions utilisateur. Étendez ChangeNotifier (ou utilisez Listenable) pour exposer l'état. Exposez des snapshots d'état immuables à la View. Injectez les Repositories dans les ViewModels via le constructeur.

Couche Data

Implémentez le modèle Repository pour isoler la logique d'accès aux données et créer une source unique de vérité.

  • Services : Créez des classes sans état pour envelopper les API externes (clients HTTP, bases de données locales, plugins de plateforme). Retournez des modèles API bruts ou des wrappers Result.
  • Repositories : Consommez un ou plusieurs Services. Transformez les modèles API bruts en Domain Models propres. Gérez la mise en cache, la synchronisation hors ligne et la logique de réessai. Exposez les Domain Models aux ViewModels.

Couche Logic (Domain - Optionnelle)

  • Use Cases : Implémentez cette couche uniquement si l'application contient une logique métier complexe qui encombre le ViewModel, ou si la logique doit être réutilisée dans plusieurs ViewModels. Extrayez cette logique dans des classes Use Case (interactor) dédiées qui se situent entre les ViewModels et les Repositories.

Structure du Projet

Organisez la base de code en utilisant une approche hybride : groupez les composants UI par fonctionnalité, et groupez les composants Data/Domain par type.

lib/
├── data/
│   ├── models/         # Modèles API
│   ├── repositories/   # Implémentations de Repository
│   └── services/       # Clients API, wrappers de stockage local
├── domain/
│   ├── models/         # Domain models propres
│   └── use_cases/      # Classes de logique métier optionnelles
└── ui/
    ├── core/           # Widgets partagés, thèmes, typographie
    └── features/
        └── [feature_name]/
            ├── view_models/
            └── views/

Flux de Travail : Implémenter une Nouvelle Fonctionnalité

Suivez ce flux de travail séquentiel lors de l'ajout d'une nouvelle fonctionnalité à l'application. Copiez la liste de contrôle pour suivre la progression.

Progression des Tâches

  • [ ] Étape 1 : Définir les Domain Models. Créez des classes de données immuables pour la fonctionnalité en utilisant freezed ou built_value.
  • [ ] Étape 2 : Implémenter les Services. Créez ou mettez à jour les classes Service pour gérer la communication API externe.
  • [ ] Étape 3 : Implémenter les Repositories. Créez le Repository pour consommer les Services et retourner des Domain Models.
  • [ ] Étape 4 : Appliquer la Logique Conditionnelle (Couche Domain).
    • Si la fonctionnalité nécessite une transformation de données complexe ou une logique entre repositories : Créez une classe Use Case.
    • Si la fonctionnalité est une opération CRUD simple : Passez à l'Étape 5.
  • [ ] Étape 5 : Implémenter le ViewModel. Créez le ViewModel en étendant ChangeNotifier. Injectez les Repositories/Use Cases requis. Exposez l'état immuable et les méthodes de commande.
  • [ ] Étape 6 : Implémenter la View. Créez le widget UI. Utilisez ListenableBuilder ou AnimatedBuilder pour écouter les changements du ViewModel.
  • [ ] Étape 7 : Injecter les Dépendances. Enregistrez le nouveau Service, Repository et ViewModel dans le conteneur d'injection de dépendances (par exemple, provider ou get_it).
  • [ ] Étape 8 : Exécuter le Validateur. Exécutez les tests unitaires pour le ViewModel et le Repository.
    • Boucle de Rétroaction : Exécutez les tests -> Examinez les défaillances -> Corrigez la logique -> Réexécutez jusqu'à ce que ce soit réussi.

Exemples

Couche Data : Service et Repository

// 1. Service (Interaction API brute)
class ApiClient {
  Future<UserApiModel> fetchUser(String id) async {
    // Implémentation GET HTTP...
  }
}

// 2. Repository (Source unique de vérité, retourne un Domain Model)
class UserRepository {
  UserRepository({required ApiClient apiClient}) : _apiClient = apiClient;

  final ApiClient _apiClient;
  User? _cachedUser;

  Future<User> getUser(String id) async {
    if (_cachedUser != null) return _cachedUser!;

    final apiModel = await _apiClient.fetchUser(id);
    _cachedUser = User(id: apiModel.id, name: apiModel.fullName); // Transformez en Domain Model
    return _cachedUser!;
  }
}

Couche UI : ViewModel et View

// 3. ViewModel (Gestion de l'état et logique de présentation)
class ProfileViewModel extends ChangeNotifier {
  ProfileViewModel({required UserRepository userRepository}) 
      : _userRepository = userRepository;

  final UserRepository _userRepository;

  User? _user;
  User? get user => _user;

  bool _isLoading = false;
  bool get isLoading => _isLoading;

  Future<void> loadProfile(String id) async {
    _isLoading = true;
    notifyListeners();

    try {
      _user = await _userRepository.getUser(id);
    } finally {
      _isLoading = false;
      notifyListeners();
    }
  }
}

// 4. View (Composant UI muet)
class ProfileView extends StatelessWidget {
  const ProfileView({super.key, required this.viewModel});

  final ProfileViewModel viewModel;

  @override
  Widget build(BuildContext context) {
    return ListenableBuilder(
      listenable: viewModel,
      builder: (context, _) {
        if (viewModel.isLoading) {
          return const Center(child: CircularProgressIndicator());
        }

        final user = viewModel.user;
        if (user == null) {
          return const Center(child: Text('User not found'));
        }

        return Column(
          children: [
            Text(user.name),
            ElevatedButton(
              onPressed: () => viewModel.loadProfile(user.id),
              child: const Text('Refresh'),
            ),
          ],
        );
      },
    );
  }
}