React Native pour débutants : Premier projet mobile complet 2025
React Native permet de créer des applications mobiles natives pour iOS et Android en utilisant…
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.
# 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
# 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
# Créer le projet
flutter create --org com.example shop_app
cd shop_app
# 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}}
# pubspec.yaml
name: shop_app
description: Application e-commerce Flutter complète
publish_to: 'none'
version: 1.0.0+1
environment:
sdk: '>=3.2.0 <4.0.0'
dependencies:
flutter:
sdk: flutter
# State Management
flutter_bloc: ^8.1.3
equatable: ^2.0.5
# Network
dio: ^5.4.0
retrofit: ^4.0.3
json_annotation: ^4.8.1
# Local Storage
hive: ^2.2.3
hive_flutter: ^1.1.0
shared_preferences: ^2.2.2
# Firebase
firebase_core: ^2.24.2
firebase_auth: ^4.15.3
cloud_firestore: ^4.13.6
firebase_storage: ^11.5.6
# UI
google_fonts: ^6.1.0
cached_network_image: ^3.3.0
shimmer: ^3.0.0
flutter_svg: ^2.0.9
carousel_slider: ^4.2.1
smooth_page_indicator: ^1.1.0
# Utils
intl: ^0.18.1
uuid: ^4.2.2
logger: ^2.0.2+1
connectivity_plus: ^5.0.2
# Routing
go_router: ^13.0.0
dev_dependencies:
flutter_test:
sdk: flutter
# Code generation
build_runner: ^2.4.7
json_serializable: ^6.7.1
hive_generator: ^2.0.1
retrofit_generator: ^8.0.6
# Linting
flutter_lints: ^3.0.1
# Testing
bloc_test: ^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
// 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
// lib/data/models/product_model.dart
import 'package:json_annotation/json_annotation.dart';
import '../../domain/entities/product.dart';
part 'product_model.g.dart';
@JsonSerializable(explicitToJson: true)
class ProductModel {
final String id;
final String name;
final String description;
final double price;
@JsonKey(name: 'discount_price')
final double? discountPrice;
final String category;
final List images;
final int stock;
final double rating;
@JsonKey(name: 'review_count')
final int reviewCount;
final Map> attributes;
@JsonKey(name: 'created_at')
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['discount_price'] != null
? (map['discount_price'] 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['review_count'] 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['created_at'] as String),
);
}
Map toHive() {
return {
'id': id,
'name': name,
'description': description,
'price': price,
'discount_price': discountPrice,
'category': category,
'images': images,
'stock': stock,
'rating': rating,
'review_count': reviewCount,
'attributes': attributes,
'created_at': createdAt.toIso8601String(),
};
}
}
// lib/domain/repositories/product_repository.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/product_repository_impl.dart
import 'package:dartz/dartz.dart';
import '../../domain/entities/product.dart';
import '../../domain/repositories/product_repository.dart';
import '../../core/error/failures.dart';
import '../../core/error/exceptions.dart';
import '../datasources/product_remote_datasource.dart';
import '../datasources/product_local_datasource.dart';
import '../../core/network/network_info.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());
}
}
}
// lib/presentation/blocs/product/product_event.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/product_state.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/product_bloc.dart
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../domain/repositories/product_repository.dart';
import '../../../core/error/failures.dart';
import 'product_event.dart';
import 'product_state.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.';
}
}
}
// lib/presentation/widgets/product_card.dart
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.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.favorite_border,
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.shopping_cart, 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),
),
),
),
);
}
}
// lib/presentation/widgets/product_carousel.dart
import 'package:flutter/material.dart';
import 'package:carousel_slider/carousel_slider.dart';
import 'package:smooth_page_indicator/smooth_page_indicator.dart';
import 'package:cached_network_image/cached_network_image.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);
},
),
],
],
);
}
}
// lib/presentation/screens/home_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../blocs/product/product_bloc.dart';
import '../blocs/product/product_event.dart';
import '../blocs/product/product_state.dart';
import '../widgets/product_card.dart';
import '../widgets/category_chip.dart';
import '../widgets/loading_shimmer.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.shopping_cart),
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.error_outline,
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'),
),
],
),
),
);
}
}
// lib/presentation/screens/product_detail_screen.dart
import 'package:flutter/material.dart';
import '../../domain/entities/product.dart';
import '../widgets/product_carousel.dart';
import '../widgets/attribute_selector.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.favorite_border),
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.shopping_cart),
label: const Text('Ajouter au panier'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
),
),
],
);
}
}
# 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=your_password
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
# 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
Vous maîtrisez maintenant Flutter pour créer des applications multiplateformes professionnelles. Cette architecture Clean + BLoC garantit maintenabilité et scalabilité.
Prochaines étapes :
Ressources:
Cet article est vivant - corrections, contre-arguments et retours de production sont les bienvenus. Trois canaux, choisissez celui qui vous convient.