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 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}}
# 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
// 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
Listitem.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
// 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: 'review count')
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['discount price'] != 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['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['createdat'] as String),
);
}
Map toHive() {
return {
'id': id,
'name': name,
'description': description,
'price': price,
'discount price': discountPrice,
'category': category,
'images': images,
'stock': stock,
'rating': rating,
'reviewcount': reviewCount,
'attributes': attributes,
'createdat': createdAt.toIso8601String(),
};
}
}
// 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/product repositoryimpl.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());
}
}
}
// 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/product bloc.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.';
}
}
}
// 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),
),
),
),
);
}
}
// 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);
},
),
],
],
);
}
}
// 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'),
),
],
),
),
);
}
}
// 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),
),
),
),
],
);
}
}
# 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
# 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.