Construindo um E-commerce com Flutter: Loja do Cliente, Carrinho e Material 3 (Parte 3)
Neste capitulo, vamos transformar nosso banco de dados em uma vitrine atraente. O que vamos implementar:
-
Lógica do Carrinho: Um serviço para gerenciar o estado das compras.
-
Tela Home (Vitrine): Carrossel de promoções e Grid de “Lançamentos”.
-
Detalhes do Produto: Uso de animações Hero para transição de imagens e botão de compra.
-
Cadastro de Cliente: Formulário completo com validação e estilo Material 3.
📦 Passo 1: Dependências e Lógica do Carrinho
Precisaremos de um pacote para o carrossel e uma forma simples de gerenciar o estado do carrinho. Adicione ao pubspec.yaml:
dependencies: # ... anteriores carousel_slider: ^4.2.1 # Vamos usar ValueNotifier nativo para o estado, sem pacotes extras por enquanto.
Crie lib/data/services/cart_service.dart:
Vamos criar um Singleton para que o carrinho seja acessível em todo o app.
import 'package:flutter/material.dart';
import '../models/product.dart';
class CartItem {
final Product product;
int quantity;
CartItem({required this.product, this.quantity = 1});
}
class CartService extends ChangeNotifier {
// Singleton
static final CartService _instance = CartService._internal();
factory CartService() => _instance;
CartService._internal();
final List<CartItem> _items = [];
List<CartItem> get items => _items;
double get total => _items.fold(0, (sum, item) => sum + (item.product.price * item.quantity));
void add(Product product) {
final index = _items.indexWhere((i) => i.product.id == product.id);
if (index >= 0) {
_items[index].quantity++;
} else {
_items.add(CartItem(product: product));
}
notifyListeners(); // Avisa a UI para atualizar
}
void remove(int productId) {
_items.removeWhere((item) => item.product.id == productId);
notifyListeners();
}
void clear() {
_items.clear();
notifyListeners();
}
}
🏠 Passo 2: Tela Home (A Vitrine)
Vamos criar uma tela rica visualmente. Usaremos CustomScrollView para ter um efeito de rolagem moderno.
Crie lib/modules/shop/store_home_screen.dart:
import 'dart:io';
import 'package:carousel_slider/carousel_slider.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:shop_app/data/models/product.dart';
import 'package:shop_app/data/services/product_service.dart';
import 'package:shop_app/data/services/cart_service.dart'; // Importe o carrinho
import 'package:shop_app/main.dart'; // isar global
class StoreHomeScreen extends StatefulWidget {
const StoreHomeScreen({super.key});
@override
State<StoreHomeScreen> createState() => _StoreHomeScreenState();
}
class _StoreHomeScreenState extends State<StoreHomeScreen> {
late ProductService _productService;
List<Product> _products = [];
final CartService _cartService = CartService();
@override
void initState() {
super.initState();
_productService = ProductService(isar);
_loadProducts();
}
void _loadProducts() async {
final data = await _productService.getAllProducts();
setState(() => _products = data);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
slivers: [
// 1. App Bar Flutuante
SliverAppBar.large(
title: const Text('Discover Shop'),
actions: [
// Ícone do Carrinho com Contador (AnimatedBuilder ouvinte)
ListenableBuilder(
listenable: _cartService,
builder: (context, child) {
return Badge(
label: Text('${_cartService.items.length}'),
isLabelVisible: _cartService.items.isNotEmpty,
child: IconButton(
icon: const Icon(Icons.shopping_cart_outlined),
onPressed: () {
// Futuro: context.push('/cart');
},
),
);
},
),
IconButton(
icon: const Icon(Icons.person_outline),
onPressed: () => context.push('/register'), // Vamos criar essa rota
),
],
),
// 2. Carrossel de Promoções
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: CarouselSlider(
options: CarouselOptions(height: 180.0, autoPlay: true, enlargeCenterPage: true),
items: [1, 2, 3].map((i) {
return Builder(
builder: (BuildContext context) {
return Container(
width: MediaQuery.of(context).size.width,
margin: const EdgeInsets.symmetric(horizontal: 5.0),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(16),
),
child: Center(
child: Text('Promoção $i\nAté 50% OFF',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineMedium)),
);
},
);
}).toList(),
),
),
),
// 3. Título da Seção
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Text("Lançamentos", style: Theme.of(context).textTheme.titleLarge),
),
),
// 4. Grid de Produtos
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 0.75,
mainAxisSpacing: 16,
crossAxisSpacing: 16,
),
delegate: SliverChildBuilderDelegate(
(context, index) {
final product = _products[index];
return GestureDetector(
onTap: () => context.push('/product/${product.id}', extra: product),
child: Card(
elevation: 0, // Flat style (M3)
color: Theme.of(context).colorScheme.surfaceContainerHighest,
clipBehavior: Clip.antiAlias,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Hero( // Animação Mágica
tag: 'product_${product.id}',
child: product.imagePath != null
? Image.file(File(product.imagePath!), fit: BoxFit.cover, width: double.infinity)
: Container(color: Colors.grey, child: const Icon(Icons.image)),
),
),
Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(product.name, style: Theme.of(context).textTheme.titleMedium, maxLines: 1),
const SizedBox(height: 4),
Text(product.formattedPrice,
style: TextStyle(fontWeight: FontWeight.bold, color: Theme.of(context).primaryColor)),
],
),
),
],
),
),
);
},
childCount: _products.length,
),
),
),
// Espaço extra no final
const SliverToBoxAdapter(child: SizedBox(height: 40)),
],
),
);
}
}
✨ Passo 3: Detalhes do Produto (Animações e Carrinho)
Aqui usamos o Hero novamente com a mesma tag da Home. Isso faz a imagem “voar” de uma tela para a outra.
Crie lib/modules/shop/product_detail_screen.dart:
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:shop_app/data/models/product.dart';
import 'package:shop_app/data/services/cart_service.dart';
class ProductDetailScreen extends StatelessWidget {
final Product product;
const ProductDetailScreen({super.key, required this.product});
@override
Widget build(BuildContext context) {
return Scaffold(
// AppBar transparente para a imagem subir
extendBodyBehindAppBar: true,
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
leading: const BackButton(style: ButtonStyle(backgroundColor: MaterialStatePropertyAll(Colors.white54))),
),
body: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Imagem Gigante com Hero
Hero(
tag: 'product_${product.id}',
child: Container(
height: 400,
width: double.infinity,
decoration: BoxDecoration(
color: Colors.grey[200],
borderRadius: const BorderRadius.vertical(bottom: Radius.circular(32)),
),
child: product.imagePath != null
? ClipRRect(
borderRadius: const BorderRadius.vertical(bottom: Radius.circular(32)),
child: Image.file(File(product.imagePath!), fit: BoxFit.cover),
)
: const Icon(Icons.image, size: 100),
),
),
Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(product.name, style: Theme.of(context).textTheme.headlineMedium),
),
Text(product.formattedPrice, style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: Theme.of(context).primaryColor)),
],
),
const SizedBox(height: 16),
Text("Descrição", style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
Text(
product.description ?? "Sem descrição detalhada para este produto incrível.",
style: Theme.of(context).textTheme.bodyLarge,
),
],
),
),
],
),
),
bottomNavigationBar: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 10, offset: const Offset(0, -5))],
),
child: SafeArea(
child: FilledButton.icon(
onPressed: () {
CartService().add(product);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${product.name} adicionado ao carrinho!')),
);
},
icon: const Icon(Icons.shopping_bag),
label: const Text("ADICIONAR AO CARRINHO", style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
),
),
),
);
}
}
📝 Passo 4: Cadastro de Cliente (Material 3)
Um formulário bonito para capturar os dados do cliente.
Crie lib/modules/checkout/customer_register_screen.dart:
import 'package:flutter/material.dart';
class CustomerRegisterScreen extends StatelessWidget {
const CustomerRegisterScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Criar Conta')),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text("Seus Dados", style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: 20),
// Campos com Design Material 3
_buildTextField(label: "Nome Completo", icon: Icons.person),
const SizedBox(height: 16),
_buildTextField(label: "E-mail", icon: Icons.email, type: TextInputType.emailAddress),
const SizedBox(height: 16),
_buildTextField(label: "CPF", icon: Icons.badge, type: TextInputType.number),
const SizedBox(height: 32),
Text("Endereço de Entrega", style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: 20),
Row(
children: [
Expanded(child: _buildTextField(label: "CEP", icon: Icons.map)),
const SizedBox(width: 16),
Expanded(flex: 2, child: _buildTextField(label: "Cidade", icon: Icons.location_city)),
],
),
const SizedBox(height: 16),
_buildTextField(label: "Rua e Número", icon: Icons.home),
const SizedBox(height: 40),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: () {
// Lógica de salvar cliente (Isar)
},
style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
child: const Text("CRIAR MINHA CONTA"),
),
)
],
),
),
);
}
Widget _buildTextField({required String label, required IconData icon, TextInputType? type}) {
return TextFormField(
keyboardType: type,
decoration: InputDecoration(
labelText: label,
prefixIcon: Icon(icon),
border: const OutlineInputBorder(borderRadius: BorderRadius.all(Radius.circular(12))),
filled: true,
// fillColor automático do M3
),
);
}
}
🧭 Passo 5: Atualizando as Rotas
Adicione as novas telas ao app_router.dart. Lembre-se que estas telas geralmente ficam fora do ShellRoute (Menu Lateral) se quisermos que elas cubram a tela inteira (Full Screen) ou tenham navegação própria de retorno.
No app_router.dart:
// ...
// Adicione fora do ShellRoute ou dentro, dependendo se quer manter o menu
GoRoute(
path: '/product/:id',
builder: (context, state) {
final product = state.extra as Product; // Passamos o objeto direto
return ProductDetailScreen(product: product);
},
),
GoRoute(
path: '/register',
builder: (context, state) => const CustomerRegisterScreen(),
),
// Atualize a rota /home para usar a StoreHomeScreen em vez da HomeScreen antiga
✅ Conclusão
Nesta Parte 3, o app ganhou cara de loja profissional:
-
Vitrine Atrativa: Carrossel e Grid com design limpo.
-
User Experience (UX): A animação Hero ao clicar no produto cria uma sensação de continuidade muito agradável.
-
Carrinho Global: O
CartServicegerencia as compras em memória. -
Formulários M3: O cadastro de cliente utiliza as diretrizes mais novas do Material Design.
Próximos Passos: Na parte final, implementaremos a tela de Checkout (Resumo do Pedido) e simularemos a finalização da compra gerando um pedido no banco de dados Isar! 🚀🛍️