Flutter : Créer votre première app cross-platform

Guide complet pour créer une application mobile cross-platform avec Flutter et Dart. Widgets, state management, navigation et bonnes pratiques pour débutants.

Guide pour créer une première application mobile avec Flutter et Dart

Flutter révolutionne le développement mobile en permettant de créer des applications iOS et Android avec une seule base de code. Ce framework développé par Google combine performance native et productivité exceptionnelle grâce à son moteur de rendu Skia et son système de widgets déclaratifs. Ce guide couvre la création complète d'une application, de l'installation aux bonnes pratiques de production.

Flutter 3.27 - Janvier 2026

Flutter 3.27 apporte des améliorations significatives : support natif de Material 3 par défaut, nouvelles animations Impeller optimisées, et intégration Dart 3.6 avec les macros expérimentales. Le framework compile désormais en natif ARM64 pour des performances optimales.

Installation et configuration de l'environnement

La mise en place de Flutter nécessite quelques étapes de configuration. Le SDK Flutter inclut tout le nécessaire : le framework, les outils de compilation et le gestionnaire de dépendances Dart.

bash
# terminal
# Téléchargement du SDK Flutter (macOS/Linux)
git clone https://github.com/flutter/flutter.git -b stable
export PATH="$PATH:`pwd`/flutter/bin"

# Vérification de l'installation et des dépendances
flutter doctor

# Création d'un nouveau projet
flutter create --org com.example mon_app
cd mon_app

# Lancement en mode développement
flutter run

La commande flutter doctor vérifie que toutes les dépendances sont installées : Android Studio, Xcode (macOS), et les émulateurs configurés.

yaml
# pubspec.yaml
name: mon_app
description: Application Flutter cross-platform
publish_to: 'none'
version: 1.0.0+1

environment:
  sdk: '>=3.6.0 <4.0.0'

dependencies:
  flutter:
    sdk: flutter
  # UI et design
  cupertino_icons: ^1.0.8
  google_fonts: ^6.2.1
  # State management
  flutter_riverpod: ^2.6.1
  # Navigation
  go_router: ^14.6.2
  # HTTP et API
  dio: ^5.7.0
  # Stockage local
  shared_preferences: ^2.3.4

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^5.0.0

flutter:
  uses-material-design: true

Ce fichier pubspec.yaml configure les dépendances essentielles pour une application Flutter moderne.

Architecture d'un projet Flutter

Une structure de projet claire facilite la maintenance et l'évolution de l'application. Cette organisation sépare clairement les responsabilités.

text
lib/
├── main.dart                 # Point d'entrée
├── app.dart                  # Configuration de l'app
├── core/
│   ├── constants/           # Couleurs, dimensions, strings
│   ├── theme/               # Thème Material 3
│   └── utils/               # Fonctions utilitaires
├── features/
│   ├── auth/                # Fonctionnalité authentification
│   │   ├── data/           # Repositories, data sources
│   │   ├── domain/         # Modèles, use cases
│   │   └── presentation/   # Screens, widgets, providers
│   └── home/               # Fonctionnalité accueil
│       ├── data/
│       ├── domain/
│       └── presentation/
├── shared/
│   ├── widgets/            # Widgets réutilisables
│   └── providers/          # Providers partagés
└── routing/
    └── app_router.dart     # Configuration des routes

Cette architecture "feature-first" regroupe tout le code relatif à une fonctionnalité dans un même dossier, facilitant la navigation et les refactorings.

Architecture Feature-First

L'approche feature-first organise le code par fonctionnalité métier plutôt que par type technique. Chaque feature contient ses propres modèles, widgets et logique, rendant le code plus modulaire et testable.

Comprendre les widgets Flutter

Les widgets constituent les briques fondamentales de Flutter. Tout est widget : les boutons, les textes, les layouts, même l'application elle-même. Flutter propose deux types de widgets : StatelessWidget (sans état) et StatefulWidget (avec état local).

lib/shared/widgets/custom_button.dartdart
import 'package:flutter/material.dart';

/// Widget bouton personnalisé réutilisable dans toute l'application.
/// Gère automatiquement les états loading et disabled.
class CustomButton extends StatelessWidget {
  // Paramètres requis et optionnels du widget
  final String label;
  final VoidCallback? onPressed;
  final bool isLoading;
  final bool isOutlined;

  // Constructeur avec paramètres nommés pour plus de clarté
  const CustomButton({
    super.key,
    required this.label,
    this.onPressed,
    this.isLoading = false,
    this.isOutlined = false,
  });

  
  Widget build(BuildContext context) {
    // Accès au thème pour des styles cohérents
    final theme = Theme.of(context);

    // Construction conditionnelle selon le type de bouton
    if (isOutlined) {
      return OutlinedButton(
        // Désactive le bouton pendant le chargement
        onPressed: isLoading ? null : onPressed,
        style: OutlinedButton.styleFrom(
          padding: const EdgeInsets.symmetric(
            horizontal: 24,
            vertical: 16,
          ),
          shape: RoundedRectangleBorder(
            borderRadius: BorderRadius.circular(12),
          ),
        ),
        child: _buildChild(theme),
      );
    }

    return FilledButton(
      onPressed: isLoading ? null : onPressed,
      style: FilledButton.styleFrom(
        padding: const EdgeInsets.symmetric(
          horizontal: 24,
          vertical: 16,
        ),
        shape: RoundedRectangleBorder(
          borderRadius: BorderRadius.circular(12),
        ),
      ),
      child: _buildChild(theme),
    );
  }

  /// Construit le contenu du bouton avec gestion du loading.
  Widget _buildChild(ThemeData theme) {
    if (isLoading) {
      return const SizedBox(
        height: 20,
        width: 20,
        child: CircularProgressIndicator(
          strokeWidth: 2,
          color: Colors.white,
        ),
      );
    }
    return Text(label);
  }
}

Ce widget encapsule la logique d'affichage d'un bouton avec gestion automatique des états de chargement.

lib/shared/widgets/user_card.dartdart
import 'package:flutter/material.dart';
import '../../features/auth/domain/models/user.dart';

/// Carte affichant les informations d'un utilisateur.
/// Utilise Material 3 avec elevation et shape cohérentes.
class UserCard extends StatelessWidget {
  final User user;
  final VoidCallback? onTap;

  const UserCard({
    super.key,
    required this.user,
    this.onTap,
  });

  
  Widget build(BuildContext context) {
    final theme = Theme.of(context);

    // Card Material 3 avec InkWell pour effet ripple
    return Card(
      // Elevation adaptative selon le thème
      elevation: 2,
      // Forme arrondie cohérente
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(16),
      ),
      // Clippage pour que le ripple respecte les bords
      clipBehavior: Clip.antiAlias,
      child: InkWell(
        onTap: onTap,
        child: Padding(
          padding: const EdgeInsets.all(16),
          child: Row(
            children: [
              // Avatar avec image ou initiales
              CircleAvatar(
                radius: 28,
                backgroundImage: user.avatarUrl != null
                    ? NetworkImage(user.avatarUrl!)
                    : null,
                child: user.avatarUrl == null
                    ? Text(user.initials)
                    : null,
              ),
              const SizedBox(width: 16),
              // Informations utilisateur
              Expanded(
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Text(
                      user.displayName,
                      style: theme.textTheme.titleMedium?.copyWith(
                        fontWeight: FontWeight.w600,
                      ),
                    ),
                    const SizedBox(height: 4),
                    Text(
                      user.email,
                      style: theme.textTheme.bodyMedium?.copyWith(
                        color: theme.colorScheme.onSurfaceVariant,
                      ),
                    ),
                  ],
                ),
              ),
              // Icône de navigation
              Icon(
                Icons.chevron_right,
                color: theme.colorScheme.onSurfaceVariant,
              ),
            ],
          ),
        ),
      ),
    );
  }
}

La composition de widgets permet de construire des interfaces complexes à partir de blocs simples et réutilisables.

State management avec Riverpod

Riverpod représente la solution moderne pour la gestion d'état en Flutter. Cette bibliothèque offre une approche déclarative, type-safe et testable pour partager l'état entre les widgets.

lib/features/auth/domain/models/user.dartdart
/// Modèle utilisateur immutable avec factory fromJson.
class User {
  final String id;
  final String email;
  final String displayName;
  final String? avatarUrl;
  final DateTime createdAt;

  const User({
    required this.id,
    required this.email,
    required this.displayName,
    this.avatarUrl,
    required this.createdAt,
  });

  /// Génère les initiales à partir du nom d'affichage.
  String get initials {
    final parts = displayName.split(' ');
    if (parts.length >= 2) {
      return '${parts[0][0]}${parts[1][0]}'.toUpperCase();
    }
    return displayName.substring(0, 2).toUpperCase();
  }

  /// Crée une instance depuis un JSON (réponse API).
  factory User.fromJson(Map<String, dynamic> json) {
    return User(
      id: json['id'] as String,
      email: json['email'] as String,
      displayName: json['display_name'] as String,
      avatarUrl: json['avatar_url'] as String?,
      createdAt: DateTime.parse(json['created_at'] as String),
    );
  }

  /// Convertit en JSON pour envoi API.
  Map<String, dynamic> toJson() {
    return {
      'id': id,
      'email': email,
      'display_name': displayName,
      'avatar_url': avatarUrl,
      'created_at': createdAt.toIso8601String(),
    };
  }
}
lib/features/auth/presentation/providers/auth_provider.dartdart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../data/repositories/auth_repository.dart';
import '../../domain/models/user.dart';

/// État d'authentification représentant les différents cas possibles.
sealed class AuthState {
  const AuthState();
}

class AuthInitial extends AuthState {
  const AuthInitial();
}

class AuthLoading extends AuthState {
  const AuthLoading();
}

class AuthAuthenticated extends AuthState {
  final User user;
  const AuthAuthenticated(this.user);
}

class AuthUnauthenticated extends AuthState {
  const AuthUnauthenticated();
}

class AuthError extends AuthState {
  final String message;
  const AuthError(this.message);
}

/// Provider pour le repository d'authentification.
final authRepositoryProvider = Provider<AuthRepository>((ref) {
  return AuthRepository();
});

/// Provider principal gérant l'état d'authentification.
final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
  final repository = ref.watch(authRepositoryProvider);
  return AuthNotifier(repository);
});

/// Notifier gérant la logique d'authentification.
class AuthNotifier extends StateNotifier<AuthState> {
  final AuthRepository _repository;

  AuthNotifier(this._repository) : super(const AuthInitial()) {
    // Vérifie l'état initial au démarrage
    checkAuthStatus();
  }

  /// Vérifie si un utilisateur est déjà connecté.
  Future<void> checkAuthStatus() async {
    state = const AuthLoading();
    try {
      final user = await _repository.getCurrentUser();
      if (user != null) {
        state = AuthAuthenticated(user);
      } else {
        state = const AuthUnauthenticated();
      }
    } catch (e) {
      state = const AuthUnauthenticated();
    }
  }

  /// Connecte un utilisateur avec email et mot de passe.
  Future<void> signIn(String email, String password) async {
    state = const AuthLoading();
    try {
      final user = await _repository.signIn(email, password);
      state = AuthAuthenticated(user);
    } catch (e) {
      state = AuthError(e.toString());
    }
  }

  /// Crée un nouveau compte utilisateur.
  Future<void> signUp(String email, String password, String displayName) async {
    state = const AuthLoading();
    try {
      final user = await _repository.signUp(email, password, displayName);
      state = AuthAuthenticated(user);
    } catch (e) {
      state = AuthError(e.toString());
    }
  }

  /// Déconnecte l'utilisateur.
  Future<void> signOut() async {
    state = const AuthLoading();
    await _repository.signOut();
    state = const AuthUnauthenticated();
  }
}

Le pattern sealed class (Dart 3.0+) garantit que tous les cas d'état sont traités lors de l'utilisation avec switch.

Prêt à réussir tes entretiens Flutter ?

Entraîne-toi avec nos simulateurs interactifs, fiches express et tests techniques.

GoRouter simplifie la navigation déclarative en Flutter. Cette bibliothèque gère les routes, les paramètres, les redirections et la navigation imbriquée.

lib/routing/app_router.dartdart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../features/auth/presentation/providers/auth_provider.dart';
import '../features/auth/presentation/screens/login_screen.dart';
import '../features/auth/presentation/screens/register_screen.dart';
import '../features/home/presentation/screens/home_screen.dart';
import '../features/home/presentation/screens/profile_screen.dart';
import '../shared/widgets/shell_scaffold.dart';

/// Provider pour le routeur avec gestion de l'authentification.
final routerProvider = Provider<GoRouter>((ref) {
  // Écoute les changements d'état d'authentification
  final authState = ref.watch(authProvider);

  return GoRouter(
    // Route initiale
    initialLocation: '/',
    // Rafraîchit le routeur quand l'auth change
    refreshListenable: GoRouterRefreshStream(ref, authProvider),
    // Gestion de la redirection selon l'authentification
    redirect: (context, state) {
      final isAuthenticated = authState is AuthAuthenticated;
      final isAuthRoute = state.matchedLocation.startsWith('/auth');

      // Non authentifié sur route protégée → login
      if (!isAuthenticated && !isAuthRoute) {
        return '/auth/login';
      }

      // Authentifié sur route auth → home
      if (isAuthenticated && isAuthRoute) {
        return '/';
      }

      return null; // Pas de redirection
    },
    routes: [
      // Routes d'authentification (sans shell)
      GoRoute(
        path: '/auth/login',
        name: 'login',
        builder: (context, state) => const LoginScreen(),
      ),
      GoRoute(
        path: '/auth/register',
        name: 'register',
        builder: (context, state) => const RegisterScreen(),
      ),
      // Routes protégées avec shell (bottom navigation)
      ShellRoute(
        builder: (context, state, child) {
          return ShellScaffold(child: child);
        },
        routes: [
          GoRoute(
            path: '/',
            name: 'home',
            builder: (context, state) => const HomeScreen(),
          ),
          GoRoute(
            path: '/profile',
            name: 'profile',
            builder: (context, state) => const ProfileScreen(),
          ),
          GoRoute(
            path: '/profile/:userId',
            name: 'userProfile',
            builder: (context, state) {
              // Extraction du paramètre de route
              final userId = state.pathParameters['userId']!;
              return ProfileScreen(userId: userId);
            },
          ),
        ],
      ),
    ],
    // Page d'erreur personnalisée
    errorBuilder: (context, state) => Scaffold(
      body: Center(
        child: Text('Page non trouvée: ${state.error}'),
      ),
    ),
  );
});

/// Stream pour déclencher le refresh du routeur.
class GoRouterRefreshStream extends ChangeNotifier {
  GoRouterRefreshStream(Ref ref, StateNotifierProvider provider) {
    ref.listen(provider, (previous, next) {
      notifyListeners();
    });
  }
}

La redirection automatique selon l'état d'authentification garantit que les routes protégées restent inaccessibles aux utilisateurs non connectés.

lib/shared/widgets/shell_scaffold.dartdart
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';

/// Scaffold avec navigation bottom bar pour les routes protégées.
class ShellScaffold extends StatelessWidget {
  final Widget child;

  const ShellScaffold({
    super.key,
    required this.child,
  });

  
  Widget build(BuildContext context) {
    return Scaffold(
      body: child,
      bottomNavigationBar: NavigationBar(
        // Détermine l'index actif selon la route
        selectedIndex: _calculateSelectedIndex(context),
        onDestinationSelected: (index) => _onItemTapped(index, context),
        destinations: const [
          NavigationDestination(
            icon: Icon(Icons.home_outlined),
            selectedIcon: Icon(Icons.home),
            label: 'Accueil',
          ),
          NavigationDestination(
            icon: Icon(Icons.person_outline),
            selectedIcon: Icon(Icons.person),
            label: 'Profil',
          ),
        ],
      ),
    );
  }

  /// Calcule l'index de navigation selon la route actuelle.
  int _calculateSelectedIndex(BuildContext context) {
    final location = GoRouterState.of(context).matchedLocation;
    if (location.startsWith('/profile')) return 1;
    return 0;
  }

  /// Navigue vers la route correspondant à l'index.
  void _onItemTapped(int index, BuildContext context) {
    switch (index) {
      case 0:
        context.goNamed('home');
      case 1:
        context.goNamed('profile');
    }
  }
}

Le NavigationBar Material 3 s'adapte automatiquement aux thèmes clair et sombre.

Écrans et formulaires

La création d'écrans interactifs combine les widgets, le state management et la validation des formulaires.

lib/features/auth/presentation/screens/login_screen.dartdart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../providers/auth_provider.dart';
import '../../../../shared/widgets/custom_button.dart';

/// Écran de connexion avec formulaire validé.
class LoginScreen extends ConsumerStatefulWidget {
  const LoginScreen({super.key});

  
  ConsumerState<LoginScreen> createState() => _LoginScreenState();
}

class _LoginScreenState extends ConsumerState<LoginScreen> {
  // Clé globale pour le formulaire
  final _formKey = GlobalKey<FormState>();
  // Contrôleurs pour les champs de texte
  final _emailController = TextEditingController();
  final _passwordController = TextEditingController();
  // État local pour la visibilité du mot de passe
  bool _obscurePassword = true;

  
  void dispose() {
    // Libération des ressources
    _emailController.dispose();
    _passwordController.dispose();
    super.dispose();
  }

  /// Soumet le formulaire de connexion.
  Future<void> _submit() async {
    // Valide tous les champs
    if (_formKey.currentState!.validate()) {
      await ref.read(authProvider.notifier).signIn(
            _emailController.text.trim(),
            _passwordController.text,
          );
    }
  }

  
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final authState = ref.watch(authProvider);
    final isLoading = authState is AuthLoading;

    // Écoute les erreurs pour afficher un snackbar
    ref.listen<AuthState>(authProvider, (previous, next) {
      if (next is AuthError) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(
            content: Text(next.message),
            backgroundColor: theme.colorScheme.error,
          ),
        );
      }
    });

    return Scaffold(
      body: SafeArea(
        child: Center(
          child: SingleChildScrollView(
            padding: const EdgeInsets.all(24),
            child: Form(
              key: _formKey,
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                crossAxisAlignment: CrossAxisAlignment.stretch,
                children: [
                  // Logo ou titre
                  Icon(
                    Icons.flutter_dash,
                    size: 80,
                    color: theme.colorScheme.primary,
                  ),
                  const SizedBox(height: 16),
                  Text(
                    'Connexion',
                    style: theme.textTheme.headlineMedium?.copyWith(
                      fontWeight: FontWeight.bold,
                    ),
                    textAlign: TextAlign.center,
                  ),
                  const SizedBox(height: 32),
                  // Champ email avec validation
                  TextFormField(
                    controller: _emailController,
                    keyboardType: TextInputType.emailAddress,
                    textInputAction: TextInputAction.next,
                    decoration: const InputDecoration(
                      labelText: 'Email',
                      hintText: 'exemple@email.com',
                      prefixIcon: Icon(Icons.email_outlined),
                    ),
                    validator: (value) {
                      if (value == null || value.isEmpty) {
                        return 'L\'email est requis';
                      }
                      // Validation basique du format email
                      if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$')
                          .hasMatch(value)) {
                        return 'Format d\'email invalide';
                      }
                      return null;
                    },
                  ),
                  const SizedBox(height: 16),
                  // Champ mot de passe avec toggle visibilité
                  TextFormField(
                    controller: _passwordController,
                    obscureText: _obscurePassword,
                    textInputAction: TextInputAction.done,
                    onFieldSubmitted: (_) => _submit(),
                    decoration: InputDecoration(
                      labelText: 'Mot de passe',
                      prefixIcon: const Icon(Icons.lock_outlined),
                      suffixIcon: IconButton(
                        icon: Icon(
                          _obscurePassword
                              ? Icons.visibility_outlined
                              : Icons.visibility_off_outlined,
                        ),
                        onPressed: () {
                          setState(() {
                            _obscurePassword = !_obscurePassword;
                          });
                        },
                      ),
                    ),
                    validator: (value) {
                      if (value == null || value.isEmpty) {
                        return 'Le mot de passe est requis';
                      }
                      if (value.length < 8) {
                        return 'Minimum 8 caractères';
                      }
                      return null;
                    },
                  ),
                  const SizedBox(height: 24),
                  // Bouton de connexion
                  CustomButton(
                    label: 'Se connecter',
                    isLoading: isLoading,
                    onPressed: _submit,
                  ),
                  const SizedBox(height: 16),
                  // Lien vers inscription
                  TextButton(
                    onPressed: () => context.goNamed('register'),
                    child: const Text('Pas de compte ? Créer un compte'),
                  ),
                ],
              ),
            ),
          ),
        ),
      ),
    );
  }
}

L'utilisation de ConsumerStatefulWidget combine l'état local (contrôleurs de formulaire) avec l'état global (Riverpod).

Gestion des contrôleurs

Les TextEditingController doivent impérativement être disposed dans la méthode dispose() pour éviter les fuites mémoire. Cette règle s'applique à tous les contrôleurs et listeners créés manuellement.

Communication HTTP avec Dio

Dio offre une API HTTP puissante avec intercepteurs, gestion des erreurs et transformation automatique des réponses.

lib/core/network/api_client.dartdart
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../constants/api_constants.dart';

/// Provider pour le client HTTP Dio.
final apiClientProvider = Provider<ApiClient>((ref) {
  return ApiClient();
});

/// Client HTTP configuré avec intercepteurs.
class ApiClient {
  late final Dio _dio;

  ApiClient() {
    _dio = Dio(
      BaseOptions(
        baseUrl: ApiConstants.baseUrl,
        connectTimeout: const Duration(seconds: 10),
        receiveTimeout: const Duration(seconds: 10),
        headers: {
          'Content-Type': 'application/json',
          'Accept': 'application/json',
        },
      ),
    );

    // Ajout des intercepteurs
    _dio.interceptors.addAll([
      _AuthInterceptor(),
      _LoggingInterceptor(),
    ]);
  }

  /// Requête GET générique.
  Future<T> get<T>(
    String path, {
    Map<String, dynamic>? queryParameters,
    T Function(dynamic)? fromJson,
  }) async {
    try {
      final response = await _dio.get(
        path,
        queryParameters: queryParameters,
      );
      if (fromJson != null) {
        return fromJson(response.data);
      }
      return response.data as T;
    } on DioException catch (e) {
      throw _handleError(e);
    }
  }

  /// Requête POST générique.
  Future<T> post<T>(
    String path, {
    dynamic data,
    T Function(dynamic)? fromJson,
  }) async {
    try {
      final response = await _dio.post(path, data: data);
      if (fromJson != null) {
        return fromJson(response.data);
      }
      return response.data as T;
    } on DioException catch (e) {
      throw _handleError(e);
    }
  }

  /// Définit le token d'authentification.
  void setAuthToken(String token) {
    _dio.options.headers['Authorization'] = 'Bearer $token';
  }

  /// Supprime le token d'authentification.
  void clearAuthToken() {
    _dio.options.headers.remove('Authorization');
  }

  /// Convertit les erreurs Dio en exceptions lisibles.
  Exception _handleError(DioException error) {
    switch (error.type) {
      case DioExceptionType.connectionTimeout:
      case DioExceptionType.sendTimeout:
      case DioExceptionType.receiveTimeout:
        return Exception('Connexion timeout. Vérifiez votre connexion.');
      case DioExceptionType.badResponse:
        final statusCode = error.response?.statusCode;
        final message = error.response?.data['message'] ?? 'Erreur serveur';
        return Exception('Erreur $statusCode: $message');
      case DioExceptionType.cancel:
        return Exception('Requête annulée');
      default:
        return Exception('Erreur réseau: ${error.message}');
    }
  }
}

/// Intercepteur pour ajouter automatiquement le token.
class _AuthInterceptor extends Interceptor {
  
  void onRequest(
    RequestOptions options,
    RequestInterceptorHandler handler,
  ) {
    // Le token est ajouté via setAuthToken()
    handler.next(options);
  }

  
  void onError(
    DioException err,
    ErrorInterceptorHandler handler,
  ) {
    // Gestion du 401 pour refresh token
    if (err.response?.statusCode == 401) {
      // TODO: Implémenter le refresh token
    }
    handler.next(err);
  }
}

/// Intercepteur de logging pour le développement.
class _LoggingInterceptor extends Interceptor {
  
  void onRequest(
    RequestOptions options,
    RequestInterceptorHandler handler,
  ) {
    print('→ ${options.method} ${options.path}');
    handler.next(options);
  }

  
  void onResponse(
    Response response,
    ResponseInterceptorHandler handler,
  ) {
    print('← ${response.statusCode} ${response.requestOptions.path}');
    handler.next(response);
  }

  
  void onError(
    DioException err,
    ErrorInterceptorHandler handler,
  ) {
    print('✗ ${err.response?.statusCode} ${err.requestOptions.path}');
    handler.next(err);
  }
}

Les intercepteurs permettent de centraliser la logique d'authentification et de logging sans polluer chaque requête.

Thème Material 3 personnalisé

Un thème cohérent garantit une expérience utilisateur uniforme. Material 3 (Material You) s'adapte aux préférences système.

lib/core/theme/app_theme.dartdart
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';

/// Configuration du thème de l'application.
class AppTheme {
  // Couleur primaire de la marque
  static const _primaryColor = Color(0xFF6750A4);

  /// Thème clair Material 3.
  static ThemeData get lightTheme {
    // Génération du color scheme depuis la couleur primaire
    final colorScheme = ColorScheme.fromSeed(
      seedColor: _primaryColor,
      brightness: Brightness.light,
    );

    return ThemeData(
      useMaterial3: true,
      colorScheme: colorScheme,
      // Typographie personnalisée
      textTheme: GoogleFonts.interTextTheme(),
      // Configuration de l'AppBar
      appBarTheme: AppBarTheme(
        centerTitle: true,
        elevation: 0,
        backgroundColor: colorScheme.surface,
        foregroundColor: colorScheme.onSurface,
      ),
      // Configuration des champs de formulaire
      inputDecorationTheme: InputDecorationTheme(
        filled: true,
        fillColor: colorScheme.surfaceContainerHighest.withOpacity(0.5),
        border: OutlineInputBorder(
          borderRadius: BorderRadius.circular(12),
          borderSide: BorderSide.none,
        ),
        enabledBorder: OutlineInputBorder(
          borderRadius: BorderRadius.circular(12),
          borderSide: BorderSide.none,
        ),
        focusedBorder: OutlineInputBorder(
          borderRadius: BorderRadius.circular(12),
          borderSide: BorderSide(color: colorScheme.primary, width: 2),
        ),
        errorBorder: OutlineInputBorder(
          borderRadius: BorderRadius.circular(12),
          borderSide: BorderSide(color: colorScheme.error),
        ),
        contentPadding: const EdgeInsets.symmetric(
          horizontal: 16,
          vertical: 16,
        ),
      ),
      // Configuration des boutons
      filledButtonTheme: FilledButtonThemeData(
        style: FilledButton.styleFrom(
          minimumSize: const Size(double.infinity, 52),
          shape: RoundedRectangleBorder(
            borderRadius: BorderRadius.circular(12),
          ),
        ),
      ),
      outlinedButtonTheme: OutlinedButtonThemeData(
        style: OutlinedButton.styleFrom(
          minimumSize: const Size(double.infinity, 52),
          shape: RoundedRectangleBorder(
            borderRadius: BorderRadius.circular(12),
          ),
        ),
      ),
      // Configuration des cartes
      cardTheme: CardTheme(
        elevation: 2,
        shape: RoundedRectangleBorder(
          borderRadius: BorderRadius.circular(16),
        ),
      ),
    );
  }

  /// Thème sombre Material 3.
  static ThemeData get darkTheme {
    final colorScheme = ColorScheme.fromSeed(
      seedColor: _primaryColor,
      brightness: Brightness.dark,
    );

    return ThemeData(
      useMaterial3: true,
      colorScheme: colorScheme,
      textTheme: GoogleFonts.interTextTheme(
        ThemeData.dark().textTheme,
      ),
      appBarTheme: AppBarTheme(
        centerTitle: true,
        elevation: 0,
        backgroundColor: colorScheme.surface,
        foregroundColor: colorScheme.onSurface,
      ),
      inputDecorationTheme: InputDecorationTheme(
        filled: true,
        fillColor: colorScheme.surfaceContainerHighest.withOpacity(0.5),
        border: OutlineInputBorder(
          borderRadius: BorderRadius.circular(12),
          borderSide: BorderSide.none,
        ),
        enabledBorder: OutlineInputBorder(
          borderRadius: BorderRadius.circular(12),
          borderSide: BorderSide.none,
        ),
        focusedBorder: OutlineInputBorder(
          borderRadius: BorderRadius.circular(12),
          borderSide: BorderSide(color: colorScheme.primary, width: 2),
        ),
        errorBorder: OutlineInputBorder(
          borderRadius: BorderRadius.circular(12),
          borderSide: BorderSide(color: colorScheme.error),
        ),
        contentPadding: const EdgeInsets.symmetric(
          horizontal: 16,
          vertical: 16,
        ),
      ),
      filledButtonTheme: FilledButtonThemeData(
        style: FilledButton.styleFrom(
          minimumSize: const Size(double.infinity, 52),
          shape: RoundedRectangleBorder(
            borderRadius: BorderRadius.circular(12),
          ),
        ),
      ),
      outlinedButtonTheme: OutlinedButtonThemeData(
        style: OutlinedButton.styleFrom(
          minimumSize: const Size(double.infinity, 52),
          shape: RoundedRectangleBorder(
            borderRadius: BorderRadius.circular(12),
          ),
        ),
      ),
      cardTheme: CardTheme(
        elevation: 2,
        shape: RoundedRectangleBorder(
          borderRadius: BorderRadius.circular(16),
        ),
      ),
    );
  }
}

ColorScheme.fromSeed génère automatiquement une palette complète et accessible à partir d'une seule couleur.

Configuration de l'application

Le point d'entrée de l'application initialise Riverpod et configure les thèmes.

lib/main.dartdart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'app.dart';

void main() {
  // Garantit l'initialisation des bindings Flutter
  WidgetsFlutterBinding.ensureInitialized();

  // Lance l'application avec le scope Riverpod
  runApp(
    const ProviderScope(
      child: App(),
    ),
  );
}
lib/app.dartdart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'core/theme/app_theme.dart';
import 'routing/app_router.dart';

/// Widget racine de l'application.
class App extends ConsumerWidget {
  const App({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
    // Récupère le routeur configuré
    final router = ref.watch(routerProvider);

    return MaterialApp.router(
      title: 'Mon App Flutter',
      debugShowCheckedModeBanner: false,
      // Configuration des thèmes
      theme: AppTheme.lightTheme,
      darkTheme: AppTheme.darkTheme,
      themeMode: ThemeMode.system,
      // Configuration du routeur
      routerConfig: router,
    );
  }
}

ThemeMode.system adapte automatiquement le thème aux préférences de l'utilisateur.

Conclusion

Flutter offre un écosystème complet pour créer des applications cross-platform performantes. La combinaison des widgets déclaratifs, de Riverpod pour l'état et de GoRouter pour la navigation permet de construire des applications maintenables et évolutives.

Checklist pour démarrer avec Flutter

  • ✅ Installer Flutter SDK et configurer l'environnement avec flutter doctor
  • ✅ Structurer le projet en features pour une meilleure organisation
  • ✅ Utiliser Riverpod pour la gestion d'état type-safe
  • ✅ Configurer GoRouter avec redirections d'authentification
  • ✅ Créer des widgets réutilisables et composables
  • ✅ Implémenter un thème Material 3 cohérent
  • ✅ Centraliser les appels HTTP avec Dio et intercepteurs

Passe à la pratique !

Teste tes connaissances avec nos simulateurs d'entretien et tests techniques.

L'approche déclarative de Flutter encourage la composition et la réutilisation. Chaque widget peut être testé indépendamment, et la hot-reload accélère considérablement le cycle de développement. Cette base solide permet d'étendre l'application vers des fonctionnalités avancées comme les animations complexes, les tests automatisés et le déploiement sur les stores.

Tags

#flutter
#dart
#mobile development
#cross-platform
#android ios

Partager

Articles similaires