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:

  1. Navegação Avançada: Configurar o GoRouter com StatefulShellRoute para manter um Menu Lateral (Drawer) persistente.

  2. Cadastro de Produtos: Implementar inclusão e edição com upload de fotos (usando a câmera/galeria).

  3. 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:

  1. Navegação Persistente: O menu lateral (Drawer) agora funciona perfeitamente com o StatefulShellRoute.

  2. Gestão de Fotos: Aprendemos a copiar imagens para o armazenamento do app e salvar apenas a referência no banco.

  3. 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! 🚀

 

Please follow and like us:
error0
fb-share-icon
Tweet 20
fb-share-icon20