Intermediaire 11 min de lecture · 2 266 mots

Flutter : Créer des applications iOS et Android avec Dart – Guide complet 2025

Estimated reading time: 13 minutes

Flutter, développé par Google, permet de créer des applications natives pour iOS, Android, web et desktop à partir d’une seule codebase. Nous allons créer une application e-commerce complète avec architecture BLoC, animations avancées et intégration Firebase.

1. Installation et configuration Flutter

1.1 Installation de Flutter SDK

# macOS avec Homebrew
brew install --cask flutter

# Ou téléchargement manuel
# https://flutter.dev/docs/get-started/install

# Ajouter Flutter au PATH (Linux/macOS)
export PATH="$PATH:pwd/flutter/bin"

# Vérifier l'installation
flutter doctor

# Résultats attendus :
# [✓] Flutter (Channel stable, 3.16.0)
# [✓] Android toolchain - develop for Android devices
# [✓] Xcode - develop for iOS and macOS
# [✓] Chrome - develop for the web
# [✓] Android Studio
# [✓] VS Code

# Accepter les licences Android
flutter doctor --android-licenses

# Installer les outils de développement
flutter precache

1.2 Configuration IDE

# VS Code : Installer les extensions
code --install-extension Dart-Code.dart-code
code --install-extension Dart-Code.flutter

# Android Studio : Installer plugins
# File > Settings > Plugins
# - Flutter
# - Dart

# Configuration Dart
flutter config --no-analytics
flutter config --enable-web

1.3 Création du projet e-commerce

# Créer le projet
flutter create --org com.example shopapp

cd shopapp

# Structure du projet
# lib/
# ├── main.dart
# ├── core/
# │   ├── constants/
# │   ├── theme/
# │   └── utils/
# ├── data/
# │   ├── models/
# │   ├── repositories/
# │   └── providers/
# ├── domain/
# │   ├── entities/
# │   └── usecases/
# └── presentation/
# ├── blocs/
# ├── screens/
# └── widgets/

# Créer les dossiers
mkdir -p lib/{core/{constants,theme,utils},data/{models,repositories,providers},domain/{entities,usecases},presentation/{blocs,screens,widgets}}

1.4 Dépendances pubspec.yaml

# pubspec.yaml
name: shopapp
description: Application e-commerce Flutter complète
publishto: 'none'
version: 1.0.0+1

environment:
  sdk: '>=3.2.0 <4.0.0'

dependencies:
  flutter:
    sdk: flutter

  # State Management
  flutterbloc: ^8.1.3
  equatable: ^2.0.5

  # Network
  dio: ^5.4.0
  retrofit: ^4.0.3
  jsonannotation: ^4.8.1

  # Local Storage
  hive: ^2.2.3
  hiveflutter: ^1.1.0
  sharedpreferences: ^2.2.2

  # Firebase
  firebasecore: ^2.24.2
  firebaseauth: ^4.15.3
  cloudfirestore: ^4.13.6
  firebasestorage: ^11.5.6

  # UI
  googlefonts: ^6.1.0
  cachednetworkimage: ^3.3.0
  shimmer: ^3.0.0
  fluttersvg: ^2.0.9
  carouselslider: ^4.2.1
  smoothpageindicator: ^1.1.0

  # Utils
  intl: ^0.18.1
  uuid: ^4.2.2
  logger: ^2.0.2+1
  connectivityplus: ^5.0.2

  # Routing
  gorouter: ^13.0.0

devdependencies:
  fluttertest:
    sdk: flutter

  # Code generation
  buildrunner: ^2.4.7
  jsonserializable: ^6.7.1
  hivegenerator: ^2.0.1
  retrofitgenerator: ^8.0.6

  # Linting
  flutterlints: ^3.0.1

  # Testing
  bloctest: ^9.1.5
  mockito: ^5.4.4

flutter:
  uses-material-design: true

  assets:
    - assets/images/
    - assets/icons/
    - assets/fonts/

  fonts:
    - family: CustomIcons
      fonts:
        - asset: assets/fonts/CustomIcons.ttf

2. Architecture Clean + BLoC

2.1 Entités du domaine

// lib/domain/entities/product.dart
import 'package:equatable/equatable.dart';

class Product extends Equatable {
  final String id;
  final String name;
  final String description;
  final double price;
  final double? discountPrice;
  final String category;
  final List images;
  final int stock;
  final double rating;
  final int reviewCount;
  final Map> attributes; // couleur, taille, etc.
  final DateTime createdAt;

  const Product({
    required this.id,
    required this.name,
    required this.description,
    required this.price,
    this.discountPrice,
    required this.category,
    required this.images,
    required this.stock,
    this.rating = 0.0,
    this.reviewCount = 0,
    this.attributes = const {},
    required this.createdAt,
  });

  bool get hasDiscount => discountPrice != null && discountPrice! < price;

  double get finalPrice => hasDiscount ? discountPrice! : price;

  double get discountPercentage => hasDiscount
      ? ((price - discountPrice!) / price  100)
      : 0.0;

  bool get isInStock => stock > 0;

  @override
  List get props => [
        id,
        name,
        description,
        price,
        discountPrice,
        category,
        images,
        stock,
        rating,
        reviewCount,
        attributes,
        createdAt,
      ];
}

// lib/domain/entities/cartitem.dart
class CartItem extends Equatable {
  final Product product;
  final int quantity;
  final Map selectedAttributes;

  const CartItem({
    required this.product,
    required this.quantity,
    this.selectedAttributes = const {},
  });

  double get totalPrice => product.finalPrice  quantity;

  CartItem copyWith({
    Product? product,
    int? quantity,
    Map? selectedAttributes,
  }) {
    return CartItem(
      product: product ?? this.product,
      quantity: quantity ?? this.quantity,
      selectedAttributes: selectedAttributes ?? this.selectedAttributes,
    );
  }

  @override
  List get props => [product, quantity, selectedAttributes];
}

// lib/domain/entities/order.dart
enum OrderStatus {
  pending,
  processing,
  shipped,
  delivered,
  cancelled,
}

class Order extends Equatable {
  final String id;
  final List items;
  final double totalAmount;
  final OrderStatus status;
  final String shippingAddress;
  final String paymentMethod;
  final DateTime createdAt;
  final DateTime? deliveredAt;

  const Order({
    required this.id,
    required this.items,
    required this.totalAmount,
    required this.status,
    required this.shippingAddress,
    required this.paymentMethod,
    required this.createdAt,
    this.deliveredAt,
  });

  @override
  List get props => [
        id,
        items,
        totalAmount,
        status,
        shippingAddress,
        paymentMethod,
        createdAt,
        deliveredAt,
      ];
}

2.2 Modèles de données (Data Layer)

// lib/data/models/productmodel.dart
import 'package:jsonannotation/jsonannotation.dart';
import '../../domain/entities/product.dart';

part 'productmodel.g.dart';

@JsonSerializable(explicitToJson: true)
class ProductModel {
  final String id;
  final String name;
  final String description;
  final double price;
  @JsonKey(name: 'discountprice')
  final double? discountPrice;
  final String category;
  final List images;
  final int stock;
  final double rating;
  @JsonKey(name: 'reviewcount')
  final int reviewCount;
  final Map> attributes;
  @JsonKey(name: 'createdat')
  final DateTime createdAt;

  ProductModel({
    required this.id,
    required this.name,
    required this.description,
    required this.price,
    this.discountPrice,
    required this.category,
    required this.images,
    required this.stock,
    this.rating = 0.0,
    this.reviewCount = 0,
    this.attributes = const {},
    required this.createdAt,
  });

  factory ProductModel.fromJson(Map json) =>
      $ProductModelFromJson(json);

  Map toJson() => $ProductModelToJson(this);

  Product toEntity() {
    return Product(
      id: id,
      name: name,
      description: description,
      price: price,
      discountPrice: discountPrice,
      category: category,
      images: images,
      stock: stock,
      rating: rating,
      reviewCount: reviewCount,
      attributes: attributes,
      createdAt: createdAt,
    );
  }

  factory ProductModel.fromEntity(Product product) {
    return ProductModel(
      id: product.id,
      name: product.name,
      description: product.description,
      price: product.price,
      discountPrice: product.discountPrice,
      category: product.category,
      images: product.images,
      stock: product.stock,
      rating: product.rating,
      reviewCount: product.reviewCount,
      attributes: product.attributes,
      createdAt: product.createdAt,
    );
  }

  // Support Hive pour cache local
  factory ProductModel.fromHive(Map map) {
    return ProductModel(
      id: map['id'] as String,
      name: map['name'] as String,
      description: map['description'] as String,
      price: (map['price'] as num).toDouble(),
      discountPrice: map['discountprice'] != null
          ? (map['discountprice'] as num).toDouble()
          : null,
      category: map['category'] as String,
      images: List.from(map['images'] as List),
      stock: map['stock'] as int,
      rating: (map['rating'] as num).toDouble(),
      reviewCount: map['reviewcount'] as int,
      attributes: Map>.from(
        (map['attributes'] as Map).map(
          (key, value) => MapEntry(
            key as String,
            List.from(value as List),
          ),
        ),
      ),
      createdAt: DateTime.parse(map['createdat'] as String),
    );
  }

  Map toHive() {
    return {
      'id': id,
      'name': name,
      'description': description,
      'price': price,
      'discountprice': discountPrice,
      'category': category,
      'images': images,
      'stock': stock,
      'rating': rating,
      'reviewcount': reviewCount,
      'attributes': attributes,
      'createdat': createdAt.toIso8601String(),
    };
  }
}

2.3 Repository Pattern

// lib/domain/repositories/productrepository.dart
import 'package:dartz/dartz.dart';
import '../entities/product.dart';
import '../../core/error/failures.dart';

abstract class ProductRepository {
  Future>> getProducts({
    String? category,
    String? search,
    int page = 1,
    int limit = 20,
  });

  Future> getProductById(String id);

  Future>> getFeaturedProducts();

  Future>> getRecommendedProducts();

  Future>> getCategories();
}

// lib/data/repositories/productrepositoryimpl.dart
import 'package:dartz/dartz.dart';
import '../../domain/entities/product.dart';
import '../../domain/repositories/productrepository.dart';
import '../../core/error/failures.dart';
import '../../core/error/exceptions.dart';
import '../datasources/productremotedatasource.dart';
import '../datasources/productlocaldatasource.dart';
import '../../core/network/networkinfo.dart';

class ProductRepositoryImpl implements ProductRepository {
  final ProductRemoteDataSource remoteDataSource;
  final ProductLocalDataSource localDataSource;
  final NetworkInfo networkInfo;

  ProductRepositoryImpl({
    required this.remoteDataSource,
    required this.localDataSource,
    required this.networkInfo,
  });

  @override
  Future>> getProducts({
    String? category,
    String? search,
    int page = 1,
    int limit = 20,
  }) async {
    try {
      if (await networkInfo.isConnected) {
        final products = await remoteDataSource.getProducts(
          category: category,
          search: search,
          page: page,
          limit: limit,
        );

        // Cache les produits localement
        await localDataSource.cacheProducts(products);

        return Right(products.map((model) => model.toEntity()).toList());
      } else {
        // Mode hors ligne : utiliser le cache
        final cachedProducts = await localDataSource.getCachedProducts();

        if (cachedProducts.isEmpty) {
          return Left(CacheFailure());
        }

        var products = cachedProducts.map((model) => model.toEntity()).toList();

        // Filtrer par catégorie si nécessaire
        if (category != null) {
          products = products
              .where((p) => p.category.toLowerCase() == category.toLowerCase())
              .toList();
        }

        // Recherche locale si nécessaire
        if (search != null && search.isNotEmpty) {
          products = products
              .where((p) =>
                  p.name.toLowerCase().contains(search.toLowerCase()) ||
                  p.description.toLowerCase().contains(search.toLowerCase()))
              .toList();
        }

        return Right(products);
      }
    } on ServerException {
      return Left(ServerFailure());
    } on CacheException {
      return Left(CacheFailure());
    }
  }

  @override
  Future> getProductById(String id) async {
    try {
      if (await networkInfo.isConnected) {
        final product = await remoteDataSource.getProductById(id);
        await localDataSource.cacheProduct(product);
        return Right(product.toEntity());
      } else {
        final cachedProduct = await localDataSource.getCachedProductById(id);

        if (cachedProduct == null) {
          return Left(CacheFailure());
        }

        return Right(cachedProduct.toEntity());
      }
    } on ServerException {
      return Left(ServerFailure());
    } on CacheException {
      return Left(CacheFailure());
    }
  }

  @override
  Future>> getFeaturedProducts() async {
    try {
      if (await networkInfo.isConnected) {
        final products = await remoteDataSource.getFeaturedProducts();
        return Right(products.map((model) => model.toEntity()).toList());
      } else {
        final cachedProducts = await localDataSource.getCachedProducts();
        // Simuler des produits en vedette (les mieux notés)
        final featured = cachedProducts
            .map((model) => model.toEntity())
            .toList()
          ..sort((a, b) => b.rating.compareTo(a.rating));

        return Right(featured.take(10).toList());
      }
    } on ServerException {
      return Left(ServerFailure());
    } on CacheException {
      return Left(CacheFailure());
    }
  }

  @override
  Future>> getRecommendedProducts() async {
    try {
      if (await networkInfo.isConnected) {
        final products = await remoteDataSource.getRecommendedProducts();
        return Right(products.map((model) => model.toEntity()).toList());
      } else {
        return Left(CacheFailure());
      }
    } on ServerException {
      return Left(ServerFailure());
    }
  }

  @override
  Future>> getCategories() async {
    try {
      if (await networkInfo.isConnected) {
        final categories = await remoteDataSource.getCategories();
        await localDataSource.cacheCategories(categories);
        return Right(categories);
      } else {
        final cachedCategories = await localDataSource.getCachedCategories();

        if (cachedCategories.isEmpty) {
          return Left(CacheFailure());
        }

        return Right(cachedCategories);
      }
    } on ServerException {
      return Left(ServerFailure());
    } on CacheException {
      return Left(CacheFailure());
    }
  }
}

2.4 BLoC pour la gestion d’état

// lib/presentation/blocs/product/productevent.dart
import 'package:equatable/equatable.dart';

abstract class ProductEvent extends Equatable {
  const ProductEvent();

  @override
  List get props => [];
}

class LoadProducts extends ProductEvent {
  final String? category;
  final String? search;
  final bool refresh;

  const LoadProducts({
    this.category,
    this.search,
    this.refresh = false,
  });

  @override
  List get props => [category, search, refresh];
}

class LoadMoreProducts extends ProductEvent {}

class LoadProductDetail extends ProductEvent {
  final String productId;

  const LoadProductDetail(this.productId);

  @override
  List get props => [productId];
}

class LoadFeaturedProducts extends ProductEvent {}

// lib/presentation/blocs/product/productstate.dart
import 'package:equatable/equatable.dart';
import '../../../domain/entities/product.dart';

abstract class ProductState extends Equatable {
  const ProductState();

  @override
  List get props => [];
}

class ProductInitial extends ProductState {}

class ProductLoading extends ProductState {}

class ProductLoaded extends ProductState {
  final List products;
  final bool hasMore;
  final int currentPage;

  const ProductLoaded({
    required this.products,
    this.hasMore = true,
    this.currentPage = 1,
  });

  ProductLoaded copyWith({
    List? products,
    bool? hasMore,
    int? currentPage,
  }) {
    return ProductLoaded(
      products: products ?? this.products,
      hasMore: hasMore ?? this.hasMore,
      currentPage: currentPage ?? this.currentPage,
    );
  }

  @override
  List get props => [products, hasMore, currentPage];
}

class ProductDetailLoaded extends ProductState {
  final Product product;

  const ProductDetailLoaded(this.product);

  @override
  List get props => [product];
}

class ProductError extends ProductState {
  final String message;

  const ProductError(this.message);

  @override
  List get props => [message];
}

// lib/presentation/blocs/product/productbloc.dart
import 'package:flutterbloc/flutterbloc.dart';
import '../../../domain/repositories/productrepository.dart';
import '../../../core/error/failures.dart';
import 'productevent.dart';
import 'productstate.dart';

class ProductBloc extends Bloc {
  final ProductRepository repository;

  ProductBloc({required this.repository}) : super(ProductInitial()) {
    on(onLoadProducts);
    on(onLoadMoreProducts);
    on(onLoadProductDetail);
    on(onLoadFeaturedProducts);
  }

  Future onLoadProducts(
    LoadProducts event,
    Emitter emit,
  ) async {
    if (event.refresh || state is! ProductLoaded) {
      emit(ProductLoading());
    }

    final result = await repository.getProducts(
      category: event.category,
      search: event.search,
      page: 1,
      limit: 20,
    );

    result.fold(
      (failure) => emit(ProductError(mapFailureToMessage(failure))),
      (products) => emit(ProductLoaded(
        products: products,
        hasMore: products.length >= 20,
        currentPage: 1,
      )),
    );
  }

  Future onLoadMoreProducts(
    LoadMoreProducts event,
    Emitter emit,
  ) async {
    if (state is ProductLoaded) {
      final currentState = state as ProductLoaded;

      if (!currentState.hasMore) return;

      final nextPage = currentState.currentPage + 1;

      final result = await repository.getProducts(
        page: nextPage,
        limit: 20,
      );

      result.fold(
        (failure) {
          // Ne pas afficher d'erreur pour le chargement de plus
        },
        (newProducts) {
          emit(currentState.copyWith(
            products: [...currentState.products, ...newProducts],
            hasMore: newProducts.length >= 20,
            currentPage: nextPage,
          ));
        },
      );
    }
  }

  Future onLoadProductDetail(
    LoadProductDetail event,
    Emitter emit,
  ) async {
    emit(ProductLoading());

    final result = await repository.getProductById(event.productId);

    result.fold(
      (failure) => emit(ProductError(mapFailureToMessage(failure))),
      (product) => emit(ProductDetailLoaded(product)),
    );
  }

  Future onLoadFeaturedProducts(
    LoadFeaturedProducts event,
    Emitter emit,
  ) async {
    emit(ProductLoading());

    final result = await repository.getFeaturedProducts();

    result.fold(
      (failure) => emit(ProductError(mapFailureToMessage(failure))),
      (products) => emit(ProductLoaded(
        products: products,
        hasMore: false,
        currentPage: 1,
      )),
    );
  }

  String mapFailureToMessage(Failure failure) {
    switch (failure.runtimeType) {
      case ServerFailure:
        return 'Erreur serveur. Veuillez réessayer.';
      case CacheFailure:
        return 'Aucune donnée disponible hors ligne.';
      default:
        return 'Erreur inattendue.';
    }
  }
}

3. Widgets personnalisés avancés

3.1 ProductCard avec animations

// lib/presentation/widgets/productcard.dart
import 'package:flutter/material.dart';
import 'package:cachednetworkimage/cachednetworkimage.dart';
import '../../domain/entities/product.dart';

class ProductCard extends StatefulWidget {
  final Product product;
  final VoidCallback onTap;
  final VoidCallback? onAddToCart;

  const ProductCard({
    Key? key,
    required this.product,
    required this.onTap,
    this.onAddToCart,
  }) : super(key: key);

  @override
  State createState() => ProductCardState();
}

class ProductCardState extends State
    with SingleTickerProviderStateMixin {
  late AnimationController controller;
  late Animation scaleAnimation;
  bool isFavorite = false;

  @override
  void initState() {
    super.initState();
    controller = AnimationController(
      duration: const Duration(milliseconds: 200),
      vsync: this,
    );

    scaleAnimation = Tween(begin: 1.0, end: 0.95).animate(
      CurvedAnimation(parent: controller, curve: Curves.easeInOut),
    );
  }

  @override
  void dispose() {
    controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTapDown: () => controller.forward(),
      onTapUp: () {
        controller.reverse();
        widget.onTap();
      },
      onTapCancel: () => controller.reverse(),
      child: ScaleTransition(
        scale: scaleAnimation,
        child: Card(
          elevation: 4,
          shape: RoundedRectangleBorder(
            borderRadius: BorderRadius.circular(16),
          ),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              buildImage(),
              Padding(
                padding: const EdgeInsets.all(12.0),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    buildTitle(),
                    const SizedBox(height: 4),
                    buildRating(),
                    const SizedBox(height: 8),
                    buildPrice(),
                    const SizedBox(height: 8),
                    buildAddToCartButton(),
                  ],
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }

  Widget buildImage() {
    return Stack(
      children: [
        ClipRRect(
          borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
          child: AspectRatio(
            aspectRatio: 1,
            child: CachedNetworkImage(
              imageUrl: widget.product.images.first,
              fit: BoxFit.cover,
              placeholder: (context, url) => Container(
                color: Colors.grey[200],
                child: const Center(
                  child: CircularProgressIndicator(),
                ),
              ),
              errorWidget: (context, url, error) => Container(
                color: Colors.grey[200],
                child: const Icon(Icons.error),
              ),
            ),
          ),
        ),
        if (widget.product.hasDiscount)
          Positioned(
            top: 8,
            left: 8,
            child: Container(
              padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
              decoration: BoxDecoration(
                color: Colors.red,
                borderRadius: BorderRadius.circular(8),
              ),
              child: Text(
                '-${widget.product.discountPercentage.toStringAsFixed(0)}%',
                style: const TextStyle(
                  color: Colors.white,
                  fontWeight: FontWeight.bold,
                  fontSize: 12,
                ),
              ),
            ),
          ),
        Positioned(
          top: 8,
          right: 8,
          child: GestureDetector(
            onTap: () {
              setState(() {
                isFavorite = !isFavorite;
              });
            },
            child: Container(
              padding: const EdgeInsets.all(8),
              decoration: BoxDecoration(
                color: Colors.white,
                shape: BoxShape.circle,
                boxShadow: [
                  BoxShadow(
                    color: Colors.black.withOpacity(0.1),
                    blurRadius: 4,
                  ),
                ],
              ),
              child: Icon(
                isFavorite ? Icons.favorite : Icons.favoriteborder,
                color: isFavorite ? Colors.red : Colors.grey,
                size: 20,
              ),
            ),
          ),
        ),
      ],
    );
  }

  Widget buildTitle() {
    return Text(
      widget.product.name,
      maxLines: 2,
      overflow: TextOverflow.ellipsis,
      style: const TextStyle(
        fontWeight: FontWeight.w600,
        fontSize: 14,
      ),
    );
  }

  Widget buildRating() {
    return Row(
      children: [
        const Icon(Icons.star, color: Colors.amber, size: 16),
        const SizedBox(width: 4),
        Text(
          widget.product.rating.toStringAsFixed(1),
          style: const TextStyle(fontSize: 12),
        ),
        const SizedBox(width: 4),
        Text(
          '(${widget.product.reviewCount})',
          style: TextStyle(
            fontSize: 12,
            color: Colors.grey[600],
          ),
        ),
      ],
    );
  }

  Widget buildPrice() {
    return Row(
      children: [
        if (widget.product.hasDiscount) ...[
          Text(
            '${widget.product.price.toStringAsFixed(2)} €',
            style: TextStyle(
              decoration: TextDecoration.lineThrough,
              color: Colors.grey[600],
              fontSize: 12,
            ),
          ),
          const SizedBox(width: 8),
        ],
        Text(
          '${widget.product.finalPrice.toStringAsFixed(2)} €',
          style: TextStyle(
            fontWeight: FontWeight.bold,
            fontSize: 16,
            color: Theme.of(context).primaryColor,
          ),
        ),
      ],
    );
  }

  Widget buildAddToCartButton() {
    if (widget.onAddToCart == null) return const SizedBox.shrink();

    return SizedBox(
      width: double.infinity,
      child: ElevatedButton.icon(
        onPressed: widget.product.isInStock ? widget.onAddToCart : null,
        icon: const Icon(Icons.shoppingcart, size: 16),
        label: Text(
          widget.product.isInStock ? 'Ajouter' : 'Rupture',
          style: const TextStyle(fontSize: 12),
        ),
        style: ElevatedButton.styleFrom(
          padding: const EdgeInsets.symmetric(vertical: 8),
          shape: RoundedRectangleBorder(
            borderRadius: BorderRadius.circular(8),
          ),
        ),
      ),
    );
  }
}

3.2 Carrousel de produits avec indicateur

// lib/presentation/widgets/productcarousel.dart
import 'package:flutter/material.dart';
import 'package:carouselslider/carouselslider.dart';
import 'package:smoothpageindicator/smoothpageindicator.dart';
import 'package:cachednetworkimage/cachednetworkimage.dart';

class ProductCarousel extends StatefulWidget {
  final List images;
  final double height;

  const ProductCarousel({
    Key? key,
    required this.images,
    this.height = 300,
  }) : super(key: key);

  @override
  State createState() => ProductCarouselState();
}

class ProductCarouselState extends State {
  int currentIndex = 0;
  final CarouselController carouselController = CarouselController();

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        CarouselSlider.builder(
          carouselController: carouselController,
          itemCount: widget.images.length,
          options: CarouselOptions(
            height: widget.height,
            viewportFraction: 1.0,
            enlargeCenterPage: false,
            enableInfiniteScroll: widget.images.length > 1,
            onPageChanged: (index, reason) {
              setState(() {
                currentIndex = index;
              });
            },
          ),
          itemBuilder: (context, index, realIndex) {
            return Hero(
              tag: 'product-image-$index',
              child: CachedNetworkImage(
                imageUrl: widget.images[index],
                fit: BoxFit.cover,
                width: double.infinity,
                placeholder: (context, url) => Container(
                  color: Colors.grey[200],
                  child: const Center(
                    child: CircularProgressIndicator(),
                  ),
                ),
                errorWidget: (context, url, error) => Container(
                  color: Colors.grey[200],
                  child: const Icon(Icons.error, size: 48),
                ),
              ),
            );
          },
        ),
        if (widget.images.length > 1) ...[
          const SizedBox(height: 16),
          AnimatedSmoothIndicator(
            activeIndex: currentIndex,
            count: widget.images.length,
            effect: ExpandingDotsEffect(
              activeDotColor: Theme.of(context).primaryColor,
              dotColor: Colors.grey[300]!,
              dotHeight: 8,
              dotWidth: 8,
              expansionFactor: 3,
            ),
            onDotClicked: (index) {
              carouselController.animateToPage(index);
            },
          ),
        ],
      ],
    );
  }
}

4. Écrans principaux

4.1 Écran Home avec catégories

// lib/presentation/screens/homescreen.dart
import 'package:flutter/material.dart';
import 'package:flutterbloc/flutterbloc.dart';
import '../blocs/product/productbloc.dart';
import '../blocs/product/productevent.dart';
import '../blocs/product/productstate.dart';
import '../widgets/productcard.dart';
import '../widgets/categorychip.dart';
import '../widgets/loadingshimmer.dart';

class HomeScreen extends StatefulWidget {
  const HomeScreen({Key? key}) : super(key: key);

  @override
  State createState() => HomeScreenState();
}

class HomeScreenState extends State {
  final ScrollController scrollController = ScrollController();
  String? selectedCategory;

  @override
  void initState() {
    super.initState();
    context.read().add(const LoadProducts());

    scrollController.addListener(onScroll);
  }

  @override
  void dispose() {
    scrollController.dispose();
    super.dispose();
  }

  void onScroll() {
    if (isBottom) {
      context.read().add(LoadMoreProducts());
    }
  }

  bool get isBottom {
    if (!scrollController.hasClients) return false;
    final maxScroll = scrollController.position.maxScrollExtent;
    final currentScroll = scrollController.offset;
    return currentScroll >= (maxScroll * 0.9);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('ShopApp'),
        actions: [
          IconButton(
            icon: const Icon(Icons.search),
            onPressed: () {
              // Navigation vers recherche
            },
          ),
          IconButton(
            icon: const Icon(Icons.shoppingcart),
            onPressed: () {
              // Navigation vers panier
            },
          ),
        ],
      ),
      body: RefreshIndicator(
        onRefresh: () async {
          context.read().add(
                LoadProducts(
                  category: selectedCategory,
                  refresh: true,
                ),
              );
        },
        child: CustomScrollView(
          controller: scrollController,
          slivers: [
            SliverToBoxAdapter(
              child: buildCategories(),
            ),
            BlocBuilder(
              builder: (context, state) {
                if (state is ProductLoading && state is! ProductLoaded) {
                  return const SliverToBoxAdapter(
                    child: LoadingShimmer(),
                  );
                }

                if (state is ProductError) {
                  return SliverToBoxAdapter(
                    child: buildError(state.message),
                  );
                }

                if (state is ProductLoaded) {
                  return SliverPadding(
                    padding: const EdgeInsets.all(16),
                    sliver: SliverGrid(
                      gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
                        crossAxisCount: 2,
                        crossAxisSpacing: 16,
                        mainAxisSpacing: 16,
                        childAspectRatio: 0.65,
                      ),
                      delegate: SliverChildBuilderDelegate(
                        (context, index) {
                          if (index >= state.products.length) {
                            return const Center(
                              child: CircularProgressIndicator(),
                            );
                          }

                          final product = state.products[index];

                          return ProductCard(
                            product: product,
                            onTap: () {
                              // Navigation vers détails
                            },
                            onAddToCart: () {
                              // Ajouter au panier
                            },
                          );
                        },
                        childCount: state.hasMore
                            ? state.products.length + 2
                            : state.products.length,
                      ),
                    ),
                  );
                }

                return const SliverToBoxAdapter(
                  child: SizedBox.shrink(),
                );
              },
            ),
          ],
        ),
      ),
    );
  }

  Widget buildCategories() {
    final categories = [
      'Tout',
      'Électronique',
      'Mode',
      'Maison',
      'Sport',
      'Livres',
    ];

    return Container(
      height: 60,
      padding: const EdgeInsets.symmetric(vertical: 8),
      child: ListView.builder(
        scrollDirection: Axis.horizontal,
        padding: const EdgeInsets.symmetric(horizontal: 16),
        itemCount: categories.length,
        itemBuilder: (context, index) {
          final category = categories[index];
          final isSelected = selectedCategory == category ||
              (category == 'Tout' && selectedCategory == null);

          return Padding(
            padding: const EdgeInsets.only(right: 8),
            child: CategoryChip(
              label: category,
              isSelected: isSelected,
              onTap: () {
                setState(() {
                  selectedCategory = category == 'Tout' ? null : category;
                });

                context.read().add(
                      LoadProducts(category: selectedCategory),
                    );
              },
            ),
          );
        },
      ),
    );
  }

  Widget buildError(String message) {
    return Center(
      child: Padding(
        padding: const EdgeInsets.all(32.0),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Icon(
              Icons.erroroutline,
              size: 64,
              color: Colors.grey[400],
            ),
            const SizedBox(height: 16),
            Text(
              message,
              textAlign: TextAlign.center,
              style: const TextStyle(fontSize: 16),
            ),
            const SizedBox(height: 16),
            ElevatedButton(
              onPressed: () {
                context.read().add(const LoadProducts());
              },
              child: const Text('Réessayer'),
            ),
          ],
        ),
      ),
    );
  }
}

4.2 Écran de détails produit

// lib/presentation/screens/productdetailscreen.dart
import 'package:flutter/material.dart';
import '../../domain/entities/product.dart';
import '../widgets/productcarousel.dart';
import '../widgets/attributeselector.dart';

class ProductDetailScreen extends StatefulWidget {
  final Product product;

  const ProductDetailScreen({
    Key? key,
    required this.product,
  }) : super(key: key);

  @override
  State createState() => ProductDetailScreenState();
}

class ProductDetailScreenState extends State {
  Map selectedAttributes = {};
  int quantity = 1;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: CustomScrollView(
        slivers: [
          SliverAppBar(
            expandedHeight: 300,
            pinned: true,
            flexibleSpace: FlexibleSpaceBar(
              background: ProductCarousel(
                images: widget.product.images,
                height: 300,
              ),
            ),
            actions: [
              IconButton(
                icon: const Icon(Icons.share),
                onPress: () {
                  // Partager le produit
                },
              ),
            ],
          ),
          SliverToBoxAdapter(
            child: Padding(
              padding: const EdgeInsets.all(16.0),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  buildHeader(),
                  const SizedBox(height: 16),
                  buildPrice(),
                  const SizedBox(height: 24),
                  buildDescription(),
                  const SizedBox(height: 24),
                  if (widget.product.attributes.isNotEmpty) ...[
                    buildAttributes(),
                    const SizedBox(height: 24),
                  ],
                  buildQuantitySelector(),
                  const SizedBox(height: 24),
                  buildActions(),
                ],
              ),
            ),
          ),
        ],
      ),
    );
  }

  Widget buildHeader() {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text(
          widget.product.category,
          style: TextStyle(
            color: Colors.grey[600],
            fontSize: 14,
          ),
        ),
        const SizedBox(height: 8),
        Text(
          widget.product.name,
          style: const TextStyle(
            fontSize: 24,
            fontWeight: FontWeight.bold,
          ),
        ),
        const SizedBox(height: 8),
        Row(
          children: [
            const Icon(Icons.star, color: Colors.amber, size: 20),
            const SizedBox(width: 4),
            Text(
              widget.product.rating.toStringAsFixed(1),
              style: const TextStyle(
                fontWeight: FontWeight.w600,
                fontSize: 16,
              ),
            ),
            const SizedBox(width: 4),
            Text(
              '(${widget.product.reviewCount} avis)',
              style: TextStyle(color: Colors.grey[600]),
            ),
          ],
        ),
      ],
    );
  }

  Widget buildPrice() {
    return Container(
      padding: const EdgeInsets.all(16),
      decoration: BoxDecoration(
        color: Theme.of(context).primaryColor.withOpacity(0.1),
        borderRadius: BorderRadius.circular(12),
      ),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.spaceBetween,
        children: [
          Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              if (widget.product.hasDiscount) ...[
                Text(
                  '${widget.product.price.toStringAsFixed(2)} €',
                  style: TextStyle(
                    decoration: TextDecoration.lineThrough,
                    color: Colors.grey[600],
                    fontSize: 16,
                  ),
                ),
                const SizedBox(height: 4),
              ],
              Text(
                '${widget.product.finalPrice.toStringAsFixed(2)} €',
                style: TextStyle(
                  fontSize: 28,
                  fontWeight: FontWeight.bold,
                  color: Theme.of(context).primaryColor,
                ),
              ),
            ],
          ),
          if (widget.product.hasDiscount)
            Container(
              padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
              decoration: BoxDecoration(
                color: Colors.red,
                borderRadius: BorderRadius.circular(8),
              ),
              child: Text(
                'PROMO -${widget.product.discountPercentage.toStringAsFixed(0)}%',
                style: const TextStyle(
                  color: Colors.white,
                  fontWeight: FontWeight.bold,
                ),
              ),
            ),
        ],
      ),
    );
  }

  Widget buildDescription() {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        const Text(
          'Description',
          style: TextStyle(
            fontSize: 18,
            fontWeight: FontWeight.bold,
          ),
        ),
        const SizedBox(height: 8),
        Text(
          widget.product.description,
          style: const TextStyle(
            fontSize: 14,
            height: 1.6,
          ),
        ),
      ],
    );
  }

  Widget buildAttributes() {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: widget.product.attributes.entries.map((entry) {
        return Padding(
          padding: const EdgeInsets.only(bottom: 16),
          child: AttributeSelector(
            label: entry.key,
            options: entry.value,
            selectedValue: selectedAttributes[entry.key],
            onChanged: (value) {
              setState(() {
                selectedAttributes[entry.key] = value;
              });
            },
          ),
        );
      }).toList(),
    );
  }

  Widget buildQuantitySelector() {
    return Row(
      children: [
        const Text(
          'Quantité:',
          style: TextStyle(
            fontSize: 16,
            fontWeight: FontWeight.w600,
          ),
        ),
        const SizedBox(width: 16),
        Container(
          decoration: BoxDecoration(
            border: Border.all(color: Colors.grey[300]!),
            borderRadius: BorderRadius.circular(8),
          ),
          child: Row(
            children: [
              IconButton(
                icon: const Icon(Icons.remove),
                onPressed: quantity > 1
                    ? () => setState(() => quantity--)
                    : null,
              ),
              Container(
                padding: const EdgeInsets.symmetric(horizontal: 16),
                child: Text(
                  '$quantity',
                  style: const TextStyle(
                    fontSize: 18,
                    fontWeight: FontWeight.bold,
                  ),
                ),
              ),
              IconButton(
                icon: const Icon(Icons.add),
                onPressed: quantity < widget.product.stock
                    ? () => setState(() => quantity++)
                    : null,
              ),
            ],
          ),
        ),
        const Spacer(),
        Text(
          '${widget.product.stock} en stock',
          style: TextStyle(
            color: widget.product.isInStock ? Colors.green : Colors.red,
            fontWeight: FontWeight.w600,
          ),
        ),
      ],
    );
  }

  Widget buildActions() {
    return Row(
      children: [
        Expanded(
          child: OutlinedButton.icon(
            onPressed: () {
              // Ajouter aux favoris
            },
            icon: const Icon(Icons.favoriteborder),
            label: const Text('Favoris'),
            style: OutlinedButton.styleFrom(
              padding: const EdgeInsets.symmetric(vertical: 16),
            ),
          ),
        ),
        const SizedBox(width: 12),
        Expanded(
          flex: 2,
          child: ElevatedButton.icon(
            onPressed: widget.product.isInStock
                ? () {
                    // Ajouter au panier
                    ScaffoldMessenger.of(context).showSnackBar(
                      const SnackBar(
                        content: Text('Produit ajouté au panier'),
                        duration: Duration(seconds: 2),
                      ),
                    );
                  }
                : null,
            icon: const Icon(Icons.shoppingcart),
            label: const Text('Ajouter au panier'),
            style: ElevatedButton.styleFrom(
              padding: const EdgeInsets.symmetric(vertical: 16),
            ),
          ),
        ),
      ],
    );
  }
}

5. Publication et distribution

5.1 Build Android (APK/AAB)

# Configurer le keystore
cd android/app

# Créer le keystore
keytool -genkey -v -keystore shop-app-release.keystore -alias shop-app -keyalg RSA -keysize 2048 -validity 10000

# Configurer android/key.properties
storePassword=yourpassword
keyPassword=your_password
keyAlias=shop-app
storeFile=shop-app-release.keystore

# Build APK
flutter build apk --release

# Build AAB (pour Google Play)
flutter build appbundle --release

# Fichiers générés :
# build/app/outputs/flutter-apk/app-release.apk
# build/app/outputs/bundle/release/app-release.aab

5.2 Build iOS (IPA)

# Configuration Xcode
open ios/Runner.xcworkspace

# Dans Xcode:
# 1. Sélectionner "Runner" > "Signing & Capabilities"
# 2. Configurer le Team et Bundle Identifier
# 3. Product > Archive

# Ou via CLI
flutter build ios --release

# Distribution
# Product > Archive > Distribute App > App Store Connect

Conclusion

Vous maîtrisez maintenant Flutter pour créer des applications multiplateformes professionnelles. Cette architecture Clean + BLoC garantit maintenabilité et scalabilité.

Prochaines étapes :

  • Implémenter la synchronisation Firebase en temps réel
  • Ajouter le paiement (Stripe, PayPal)
  • Configurer les notifications push
  • Optimiser les performances avec DevTools
  • Ajouter l’internationalisation complète
  • Ressources:

  • Documentation Flutter : https://flutter.dev
  • Pub.dev packages : https://pub.dev
  • Flutter Community : https://flutter.dev/community

Une remarque, un retour ?

Cet article est vivant — corrections, contre-arguments et retours de production sont les bienvenus. Trois canaux, choisissez celui qui vous convient.