Construindo um E-commerce com Flutter: Grid de Produtos, Fotos e Navegação com GoRouter (Parte 2)
Esta é a Parte 2 da nossa série. Na primeira parte, construímos a fundação: temas, banco de dados Isar e modelos básicos. Agora, vamos dar vida ao app criando as telas principais, o sistema de navegação robusto e o cadastro de produtos com fotos.
Neste artigo, vamos transformar nossa base em um aplicativo navegável. Focaremos em:
-
Navegação Avançada: Configurar o
GoRoutercomStatefulShellRoutepara manter um Menu Lateral (Drawer) persistente. -
Cadastro de Produtos: Implementar inclusão e edição com upload de fotos (usando a câmera/galeria).
-
Grid de Produtos: Uma tela de listagem bonita e responsiva.
📦 Passo 1: Atualizando Dependências
Precisamos de pacotes para a navegação e para lidar com imagens. Adicione ao pubspec.yaml:
dependencies: # ... (dependências anteriores) go_router: ^14.2.0 image_picker: ^1.1.2 path_provider: ^2.1.2 # Para salvar a imagem localmente path: ^1.9.0 # Utilitários de arquivo
Rode flutter pub get.
📸 Passo 2: Atualizando o Modelo e Serviço (Fotos)
Para salvar fotos, não devemos salvar a imagem binária (blob) no banco, pois deixa o app lento e o banco gigante. A boa prática é salvar a imagem no sistema de arquivos do celular e gravar apenas o caminho (path) no banco.
Atualize lib/data/models/product.dart:
import 'package:isar/isar.dart';
part 'product.g.dart';
@collection
class Product {
Id id = Isar.autoIncrement;
late String name;
String? description;
late double price;
String? imagePath; // Novo campo para o caminho da foto local
@Index()
late String category;
bool isFavorite = false;
String get formattedPrice => 'R\$ ${price.toStringAsFixed(2).replaceAll('.', ',')}';
}
(Não esqueça de rodar dart run build_runner build)
Crie lib/data/services/product_service.dart:
Aqui centralizamos a lógica de salvar a imagem e o produto.
import 'dart:io';
import 'package:isar/isar.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as path;
import '../models/product.dart';
class ProductService {
final Isar isar;
ProductService(this.isar);
// Salva a imagem no diretório seguro do App e retorna o novo caminho
Future<String?> _saveImageToDisk(String sourcePath) async {
final directory = await getApplicationDocumentsDirectory();
final fileName = path.basename(sourcePath);
// Cria um caminho único para evitar sobrescrita se tiver nomes iguais
final uniqueName = '${DateTime.now().millisecondsSinceEpoch}_$fileName';
final destination = '${directory.path}/$uniqueName';
// Copia a imagem do cache (picker) para o diretório permanente
await File(sourcePath).copy(destination);
return destination;
}
Future<void> saveProduct(Product product, String? tempImagePath) async {
await isar.writeTxn(() async {
// Se houver uma nova imagem temporária, salve-a permanentemente
if (tempImagePath != null) {
final permanentPath = await _saveImageToDisk(tempImagePath);
product.imagePath = permanentPath;
}
await isar.products.put(product);
});
}
Future<List<Product>> getAllProducts() async {
return await isar.products.where().findAll();
}
Future<void> deleteProduct(int id) async {
await isar.writeTxn(() async {
await isar.products.delete(id);
});
}
}
🧭 Passo 3: Navegação com Menu Lateral (GoRouter)
Vamos usar o StatefulShellRoute. Isso permite que o Menu Lateral (Drawer) ou Menu Inferior permaneça fixo enquanto trocamos apenas o “miolo” da tela, preservando o estado de rolagem e variáveis.
Crie lib/router/app_router.dart:
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../modules/shell/main_shell.dart'; // Criaremos abaixo
import '../modules/home/home_screen.dart';
import '../modules/admin/product_list_screen.dart'; // Criaremos abaixo
import '../modules/admin/product_form_screen.dart'; // Criaremos abaixo
final GlobalKey<NavigatorState> _rootNavigatorKey = GlobalKey<NavigatorState>();
final appRouter = GoRouter(
navigatorKey: _rootNavigatorKey,
initialLocation: '/home',
routes: [
// ShellRoute mantém o Menu (MainShell) fixo
StatefulShellRoute.indexedStack(
builder: (context, state, navigationShell) {
return MainShell(navigationShell: navigationShell);
},
branches: [
// Ramo 0: Home (Loja)
StatefulShellBranch(
routes: [
GoRoute(
path: '/home',
builder: (context, state) => const HomeScreen(),
),
],
),
// Ramo 1: Admin (Gestão)
StatefulShellBranch(
routes: [
GoRoute(
path: '/admin',
builder: (context, state) => const ProductListScreen(),
routes: [
// Sub-rota para adicionar/editar (cobre a tela toda)
GoRoute(
path: 'edit', // URL final: /admin/edit
parentNavigatorKey: _rootNavigatorKey, // Sai do Shell para cobrir menu
builder: (context, state) {
final product = state.extra; // Recebe objeto para edição
return ProductFormScreen(product: product);
},
),
],
),
],
),
],
),
],
);
Crie lib/modules/shell/main_shell.dart:
Este é o esqueleto do nosso app com o Menu.
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
class MainShell extends StatelessWidget {
final StatefulNavigationShell navigationShell;
const MainShell({super.key, required this.navigationShell});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Flutter Shop')),
// O Drawer é o nosso Menu Lateral
drawer: Drawer(
child: ListView(
padding: EdgeInsets.zero,
children: [
const UserAccountsDrawerHeader(
accountName: Text("Admin"),
accountEmail: Text("admin@shop.com"),
currentAccountPicture: CircleAvatar(child: Icon(Icons.person)),
),
ListTile(
leading: const Icon(Icons.store),
title: const Text('Loja Virtual'),
selected: navigationShell.currentIndex == 0,
onTap: () {
navigationShell.goBranch(0);
Navigator.pop(context); // Fecha o drawer após clicar
},
),
ListTile(
leading: const Icon(Icons.inventory),
title: const Text('Gerenciar Produtos'),
selected: navigationShell.currentIndex == 1,
onTap: () {
navigationShell.goBranch(1);
Navigator.pop(context);
},
),
],
),
),
body: navigationShell, // Aqui renderiza a HomeScreen ou ProductListScreen
);
}
}
📝 Passo 4: Cadastro de Produtos com Foto (Formulário)
Esta tela usará o ImagePicker e o nosso ProductService.
Crie lib/modules/admin/product_form_screen.dart:
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:shop_app/data/models/product.dart';
import 'package:shop_app/data/services/product_service.dart';
import 'package:shop_app/main.dart'; // Para acessar o isar global (simplificação)
class ProductFormScreen extends StatefulWidget {
final dynamic product; // Recebe Product? ou null
const ProductFormScreen({super.key, this.product});
@override
State<ProductFormScreen> createState() => _ProductFormScreenState();
}
class _ProductFormScreenState extends State<ProductFormScreen> {
final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController();
final _priceController = TextEditingController();
String? _selectedImagePath;
late ProductService _service;
@override
void initState() {
super.initState();
_service = ProductService(isar); // isar global do main.dart (ou use GetIt)
if (widget.product != null) {
final p = widget.product as Product;
_nameController.text = p.name;
_priceController.text = p.price.toString();
_selectedImagePath = p.imagePath;
}
}
Future<void> _pickImage() async {
final picker = ImagePicker();
final pickedFile = await picker.pickImage(source: ImageSource.gallery);
if (pickedFile != null) {
setState(() => _selectedImagePath = pickedFile.path);
}
}
void _save() async {
if (_formKey.currentState!.validate()) {
final isEditing = widget.product != null;
final product = isEditing ? (widget.product as Product) : Product();
product.name = _nameController.text;
product.price = double.tryParse(_priceController.text) ?? 0.0;
product.category = "Geral"; // Categoria fixa para simplificar esta parte
// Passamos o path temporário apenas se for uma nova imagem selecionada
await _service.saveProduct(product, _selectedImagePath);
if (mounted) Navigator.pop(context);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(widget.product == null ? 'Novo Produto' : 'Editar Produto')),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Form(
key: _formKey,
child: Column(
children: [
// Área da Imagem
GestureDetector(
onTap: _pickImage,
child: Container(
height: 200,
width: double.infinity,
decoration: BoxDecoration(
color: Colors.grey[200],
border: Border.all(color: Colors.grey),
borderRadius: BorderRadius.circular(10),
),
child: _selectedImagePath == null
? const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [Icon(Icons.camera_alt, size: 50), Text("Toque para adicionar foto")],
)
: Image.file(File(_selectedImagePath!), fit: BoxFit.cover),
),
),
const SizedBox(height: 20),
TextFormField(
controller: _nameController,
decoration: const InputDecoration(labelText: 'Nome do Produto', border: OutlineInputBorder()),
validator: (v) => v!.isEmpty ? 'Obrigatório' : null,
),
const SizedBox(height: 10),
TextFormField(
controller: _priceController,
decoration: const InputDecoration(labelText: 'Preço', border: OutlineInputBorder(), prefixText: 'R\$ '),
keyboardType: TextInputType.number,
validator: (v) => v!.isEmpty ? 'Obrigatório' : null,
),
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
child: ElevatedButton(onPressed: _save, child: const Text("SALVAR PRODUTO")),
)
],
),
),
),
);
}
}
🛒 Passo 5: Grid de Produtos (Lista Admin)
Uma tela para o administrador ver o grid, excluir e clicar para editar.
Crie lib/modules/admin/product_list_screen.dart:
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:shop_app/data/services/product_service.dart';
import 'package:shop_app/main.dart'; // isar global
import '../../data/models/product.dart';
class ProductListScreen extends StatefulWidget {
const ProductListScreen({super.key});
@override
State<ProductListScreen> createState() => _ProductListScreenState();
}
class _ProductListScreenState extends State<ProductListScreen> {
late ProductService _service;
List<Product> _products = [];
@override
void initState() {
super.initState();
_service = ProductService(isar);
_loadData();
}
void _loadData() async {
final data = await _service.getAllProducts();
setState(() => _products = data);
}
@override
Widget build(BuildContext context) {
return Scaffold(
floatingActionButton: FloatingActionButton(
child: const Icon(Icons.add),
onPressed: () async {
await context.push('/admin/edit');
_loadData(); // Recarrega ao voltar
},
),
body: _products.isEmpty
? const Center(child: Text("Nenhum produto cadastrado."))
: GridView.builder(
padding: const EdgeInsets.all(8),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2, // 2 Colunas
childAspectRatio: 0.8, // Altura um pouco maior que a largura
crossAxisSpacing: 10,
mainAxisSpacing: 10,
),
itemCount: _products.length,
itemBuilder: (context, index) {
final product = _products[index];
return Card(
clipBehavior: Clip.antiAlias,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: product.imagePath != null
? Image.file(File(product.imagePath!), fit: BoxFit.cover)
: Container(color: Colors.grey[300], child: const Icon(Icons.image_not_supported)),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(product.name, style: const TextStyle(fontWeight: FontWeight.bold), maxLines: 1, overflow: TextOverflow.ellipsis),
Text(product.formattedPrice, style: TextStyle(color: Theme.of(context).primaryColor)),
],
),
),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
IconButton(
icon: const Icon(Icons.edit, size: 20),
onPressed: () async {
await context.push('/admin/edit', extra: product);
_loadData();
},
),
IconButton(
icon: const Icon(Icons.delete, size: 20, color: Colors.red),
onPressed: () async {
await _service.deleteProduct(product.id);
_loadData();
},
),
],
)
],
),
);
},
),
);
}
}
✅ Conclusão
Nesta Parte 2, implementamos funcionalidades críticas:
-
Navegação Persistente: O menu lateral (Drawer) agora funciona perfeitamente com o
StatefulShellRoute. -
Gestão de Fotos: Aprendemos a copiar imagens para o armazenamento do app e salvar apenas a referência no banco.
-
Layout em Grid: Criamos uma visualização profissional para o catálogo.
Na próxima parte, focaremos na Tela de Home (Loja) para o cliente final, com carrossel de promoções, adição ao carrinho e animações de detalhes do produto! 🚀