Construindo um App de Controle de Compras com Flutter e Isar: Categorias, BLoC e Leitor de Código de Barras (Parte 2)
Na Parte 1, estabelecemos a base do nosso aplicativo de Controle de Compras com Flutter e o poderoso banco de dados Isar. Agora, é hora de expandir as funcionalidades e aprimorar a arquitetura para torná-lo ainda mais útil e robusto.
Neste artigo, vamos mergulhar nos seguintes tópicos:
-
Categorias de Produtos: Adicionar uma nova entidade (
Category) para organizar os itens (Hortifruti, Biscoitos, etc.). -
Cálculo Total do Carrinho: Exibir o valor total dos itens selecionados (ou de todos os itens).
-
Gerenciamento de Estado com BLoC: Refatorar a lógica da lista de compras para usar o padrão BLoC, garantindo escalabilidade e testabilidade.
-
Leitor de Código de Barras: Integrar a câmera para adicionar produtos rapidamente via código de barras.
-
Novas Opções e UI: Pequenos ajustes na interface para acomodar as novas funcionalidades.
Vamos transformar nossa lista de compras simples em um verdadeiro assistente de mercado!
📦 Passo 1: Adicionando Novas Dependências
Para BLoC e o leitor de código de barras, precisaremos de algumas adições no pubspec.yaml:
dependencies:
flutter:
sdk: flutter
isar_community: ^3.3.0
isar_community_flutter_libs: ^3.3.0
path_provider: ^2.1.5
intl: ^0.20.2
# BLoC para gerenciamento de estado
flutter_bloc: ^9.1.1
equatable: ^2.0.8
# Leitor de Código de Barras
mobile_scanner: ^5.2.3
dev_dependencies:
flutter_test:
sdk: flutter
build_runner: ^2.4.13
isar_community_generator: ^3.3.0
bloc_test: ^10.0.0 # Para testes futuros
Após salvar, rode flutter pub get.
🛠️ Passo 2: Modelagem de Dados – Categorias
Vamos criar uma nova “Collection” no Isar para nossas categorias de produtos.
Crie lib/collections/category.dart:
import 'package:isar/isar.dart';
part 'category.g.dart';
@collection
class Category {
Id id = Isar.autoIncrement;
late String name;
String? description;
List<String>? imageUrls; // Para imagens representativas da categoria
Category({required this.name, this.description, this.imageUrls});
}
E vamos ligar cada ShoppingItem a uma Category (opcionalmente, um item pode não ter categoria).
Atualize lib/collections/shopping_item.dart:
import 'package:isar/isar.dart';
import 'category.dart'; // Importe a nova collection
part 'shopping_item.g.dart';
@collection
class ShoppingItem {
Id id = Isar.autoIncrement;
late String name;
late double price;
int quantity = 1;
bool isBought = false;
DateTime createdAt = DateTime.now();
String? barcode; // Novo campo para o código de barras
// Relacionamento com Categoria (referência lazy)
final category = IsarLink<Category>();
}
Importante: Rode dart run build_runner build novamente para que o Isar gere o código para a nova Category e atualize o ShoppingItem.
⚙️ Passo 3: Evoluindo o Serviço Isar
Nosso IsarService precisará de métodos para gerenciar categorias e um método para buscar itens pelo código de barras.
Atualize lib/services/isar_service.dart:
import 'package:isar/isar.dart';
import 'package:path_provider/path_provider.dart';
import '../collections/shopping_item.dart';
import '../collections/category.dart'; // Nova importação
class IsarService {
late Future<Isar> db;
IsarService() {
db = openDB();
}
Future<Isar> openDB() async {
final dir = await getApplicationDocumentsDirectory();
if (Isar.instanceNames.isEmpty) {
return await Isar.open(
[ShoppingItemSchema, CategorySchema], // Adicione CategorySchema aqui
directory: dir.path,
);
}
return Future.value(Isar.getInstance());
}
// --- Métodos de ShoppingItem (existentes, com pequenas melhorias) ---
Future<void> saveItem(ShoppingItem item) async {
final isar = await db;
await isar.writeTxn(() async {
await isar.shoppingItems.put(item);
// Se o item tem categoria, salve a categoria também se for nova
await item.category.save();
});
}
Stream<List<ShoppingItem>> listenToItems({int? categoryId}) async* {
final isar = await db;
// Filtra por categoria, se fornecido
if (categoryId != null) {
yield* isar.shoppingItems
.filter()
.category((q) => q.idEqualTo(categoryId))
.sortByCreatedAtDesc()
.watch(fireImmediately: true);
} else {
yield* isar.shoppingItems.where().sortByCreatedAtDesc().watch(fireImmediately: true);
}
}
Future<ShoppingItem?> getItemByBarcode(String barcode) async {
final isar = await db;
return await isar.shoppingItems.filter().barcodeEqualTo(barcode).findFirst();
}
// --- Novos Métodos para Category ---
Future<void> saveCategory(Category category) async {
final isar = await db;
await isar.writeTxn(() async {
await isar.categorys.put(category);
});
}
Stream<List<Category>> listenToCategories() async* {
final isar = await db;
yield* isar.categorys.where().sortByName().watch(fireImmediately: true);
}
Future<void> deleteCategory(int id) async {
final isar = await db;
await isar.writeTxn(() async {
// Opcional: Se quiser, adicione lógica para lidar com itens nesta categoria
await isar.categorys.delete(id);
});
}
}
🧠 Passo 4: Gerenciamento de Estado com BLoC
Vamos criar um BLoC para a nossa lista de compras. Ele será responsável por interagir com o IsarService e notificar a UI sobre as mudanças.
4.1 Eventos (shopping_list_event.dart)
part of 'shopping_list_bloc.dart';
abstract class ShoppingListEvent extends Equatable {
const ShoppingListEvent();
@override
List<Object> get props => [];
}
class LoadShoppingList extends ShoppingListEvent {
final int? categoryId; // Para carregar por categoria
const LoadShoppingList({this.categoryId});
}
class AddShoppingItem extends ShoppingListEvent {
final String name;
final double price;
final int quantity;
final String? barcode;
final Category? category;
const AddShoppingItem({
required this.name,
required this.price,
required this.quantity,
this.barcode,
this.category,
});
}
class ToggleItemStatus extends ShoppingListEvent {
final ShoppingItem item;
const ToggleItemStatus(this.item);
}
class DeleteShoppingItem extends ShoppingListEvent {
final int itemId;
const DeleteShoppingItem(this.itemId);
}
// Eventos de Categoria
class LoadCategories extends ShoppingListEvent {}
class AddCategory extends ShoppingListEvent {
final String name;
const AddCategory(this.name);
}
4.2 Estados (shopping_list_state.dart)
part of 'shopping_list_bloc.dart';
abstract class ShoppingListState extends Equatable {
const ShoppingListState();
@override
List<Object> get props => [];
}
class ShoppingListLoading extends ShoppingListState {}
class ShoppingListLoaded extends ShoppingListState {
final List<ShoppingItem> items;
final List<Category> categories;
final double totalValue;
final double totalBoughtValue;
const ShoppingListLoaded({
this.items = const [],
this.categories = const [],
this.totalValue = 0.0,
this.totalBoughtValue = 0.0,
});
ShoppingListLoaded copyWith({
List<ShoppingItem>? items,
List<Category>? categories,
double? totalValue,
double? totalBoughtValue,
}) {
return ShoppingListLoaded(
items: items ?? this.items,
categories: categories ?? this.categories,
totalValue: totalValue ?? this.totalValue,
totalBoughtValue: totalBoughtValue ?? this.totalBoughtValue,
);
}
@override
List<Object> get props => [items, categories, totalValue, totalBoughtValue];
}
class ShoppingListError extends ShoppingListState {
final String message;
const ShoppingListError(this.message);
}
4.3 O BLoC (shopping_list_bloc.dart)
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';
import '../collections/shopping_item.dart';
import '../collections/category.dart';
import '../services/isar_service.dart';
part 'shopping_list_event.dart';
part 'shopping_list_state.dart';
class ShoppingListBloc extends Bloc<ShoppingListEvent, ShoppingListState> {
final IsarService _isarService;
StreamSubscription? _itemsSubscription;
StreamSubscription? _categoriesSubscription;
ShoppingListBloc(this._isarService) : super(ShoppingListLoading()) {
on<LoadShoppingList>(_onLoadShoppingList);
on<AddShoppingItem>(_onAddShoppingItem);
on<ToggleItemStatus>(_onToggleItemStatus);
on<DeleteShoppingItem>(_onDeleteShoppingItem);
on<LoadCategories>(_onLoadCategories);
on<AddCategory>(_onAddCategory);
// Começa a ouvir os streams do Isar assim que o BLoC é criado
_itemsSubscription = _isarService.listenToItems().listen((items) {
_updateStateWithItems(items);
});
_categoriesSubscription = _isarService.listenToCategories().listen((categories) {
_updateStateWithCategories(categories);
});
}
// Descalcula os totais
void _updateStateWithItems(List<ShoppingItem> items) {
final total = items.fold(0.0, (sum, item) => sum + (item.price * item.quantity));
final totalBought = items.where((item) => item.isBought).fold(0.0, (sum, item) => sum + (item.price * item.quantity));
if (state is ShoppingListLoaded) {
emit((state as ShoppingListLoaded).copyWith(
items: items,
totalValue: total,
totalBoughtValue: totalBought,
));
} else {
emit(ShoppingListLoaded(
items: items,
totalValue: total,
totalBoughtValue: totalBought,
categories: (state is ShoppingListLoaded) ? (state as ShoppingListLoaded).categories : [],
));
}
}
void _updateStateWithCategories(List<Category> categories) {
if (state is ShoppingListLoaded) {
emit((state as ShoppingListLoaded).copyWith(categories: categories));
} else {
emit(ShoppingListLoaded(categories: categories));
}
}
Future<void> _onLoadShoppingList(LoadShoppingList event, Emitter<ShoppingListState> emit) async {
// A lógica de carregamento inicial já está nos streams, aqui podemos apenas emitir o estado atual
// Ou re-emitir um loading para "forçar" um refresh visual
emit(ShoppingListLoading());
// Os listeners já vão atualizar o estado para Loaded
}
Future<void> _onAddShoppingItem(AddShoppingItem event, Emitter<ShoppingListState> emit) async {
try {
final newItem = ShoppingItem()
..name = event.name
..price = event.price
..quantity = event.quantity
..barcode = event.barcode;
// Ligar o item à categoria, se houver
if (event.category != null) {
newItem.category.value = event.category;
}
await _isarService.saveItem(newItem);
} catch (e) {
emit(ShoppingListError("Falha ao adicionar item: $e"));
}
}
Future<void> _onToggleItemStatus(ToggleItemStatus event, Emitter<ShoppingListState> emit) async {
try {
await _isarService.toggleStatus(event.item);
} catch (e) {
emit(ShoppingListError("Falha ao atualizar status: $e"));
}
}
Future<void> _onDeleteShoppingItem(DeleteShoppingItem event, Emitter<ShoppingListState> emit) async {
try {
await _isarService.deleteItem(event.itemId);
} catch (e) {
emit(ShoppingListError("Falha ao deletar item: $e"));
}
}
Future<void> _onLoadCategories(LoadCategories event, Emitter<ShoppingListState> emit) async {
// Já está sendo ouvido via stream, mas pode ser usado para um refresh manual
}
Future<void> _onAddCategory(AddCategory event, Emitter<ShoppingListState> emit) async {
try {
final newCategory = Category(name: event.name);
await _isarService.saveCategory(newCategory);
} catch (e) {
emit(ShoppingListError("Falha ao adicionar categoria: $e"));
}
}
@override
Future<void> close() {
_itemsSubscription?.cancel();
_categoriesSubscription?.cancel();
return super.close();
}
}
📱 Passo 5: Integrando a UI com BLoC e Novas Funcionalidades
Nosso HomeScreen será refatorado para usar o BLoC e adicionar as novas opções.
5.1 main.dart (Injetando o BLoC)
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'screens/home_screen.dart';
import 'services/isar_service.dart';
import 'blocs/shopping_list/shopping_list_bloc.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized(); // Garante que o Flutter esteja inicializado antes de usar o Isar
final isarService = IsarService(); // Cria uma única instância do serviço
runApp(
BlocProvider(
create: (context) => ShoppingListBloc(isarService), // Injeta o BLoC
child: const MyApp(),
),
);
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Controle de Compras',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.green),
useMaterial3: true,
),
home: const HomeScreen(),
debugShowCheckedModeBanner: false,
);
}
}
5.2 home_screen.dart (Consumindo o BLoC, Categorias e Totais)
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:intl/intl.dart';
import '../blocs/shopping_list/shopping_list_bloc.dart';
import '../collections/shopping_item.dart';
import '../collections/category.dart';
import 'scanner_screen.dart'; // Criaremos esta tela
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
final NumberFormat currencyFormatter = NumberFormat.simpleCurrency(locale: 'pt_BR');
@override
void initState() {
super.initState();
// Dispara o evento inicial para carregar dados
context.read<ShoppingListBloc>().add(LoadShoppingList());
context.read<ShoppingListBloc>().add(LoadCategories());
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Minha Lista de Compras'),
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
actions: [
IconButton(
icon: const Icon(Icons.category),
onPressed: () => _showManageCategoriesDialog(context),
tooltip: 'Gerenciar Categorias',
),
],
),
body: BlocBuilder<ShoppingListBloc, ShoppingListState>(
builder: (context, state) {
if (state is ShoppingListLoading) {
return const Center(child: CircularProgressIndicator());
}
if (state is ShoppingListError) {
return Center(child: Text('Erro: ${state.message}'));
}
if (state is ShoppingListLoaded) {
return Column(
children: [
// Totais do Carrinho
Padding(
padding: const EdgeInsets.all(16.0),
child: Card(
elevation: 4,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildTotalColumn('Total Geral', state.totalValue, Colors.green),
_buildTotalColumn('Total Comprado', state.totalBoughtValue, Colors.blue),
],
),
),
),
),
// Categorias (Horizontal Scroll)
if (state.categories.isNotEmpty)
SizedBox(
height: 50,
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16.0),
itemCount: state.categories.length + 1, // +1 para "Todos"
itemBuilder: (ctx, i) {
if (i == 0) {
return _buildCategoryChip('Todos', null);
}
final category = state.categories[i - 1];
return _buildCategoryChip(category.name, category.id);
},
),
),
const Divider(),
// Lista de Itens
Expanded(
child: state.items.isEmpty
? const Center(child: Text('Nenhum item na lista.'))
: ListView.separated(
itemCount: state.items.length,
separatorBuilder: (ctx, i) => const Divider(indent: 16, endIndent: 16),
itemBuilder: (context, index) {
final item = state.items[index];
return ListTile(
leading: Checkbox(
value: item.isBought,
onChanged: (bool? value) {
context.read<ShoppingListBloc>().add(ToggleItemStatus(item));
},
),
title: Text(
item.name,
style: TextStyle(
decoration: item.isBought ? TextDecoration.lineThrough : null,
color: item.isBought ? Colors.grey : Colors.black,
),
),
subtitle: Text(
'${item.quantity}x - ${currencyFormatter.format(item.price)} '
'${item.category.value != null ? '(${item.category.value!.name})' : ''}',
),
trailing: IconButton(
icon: const Icon(Icons.delete, color: Colors.redAccent),
onPressed: () {
context.read<ShoppingListBloc>().add(DeleteShoppingItem(item.id!));
},
),
);
},
),
),
],
);
}
return const SizedBox.shrink();
},
),
floatingActionButton: FloatingActionButton(
onPressed: () => _showAddItemDialog(context),
child: const Icon(Icons.add),
),
);
}
Widget _buildTotalColumn(String title, double value, Color color) {
return Column(
children: [
Text(title, style: TextStyle(fontSize: 14, color: Colors.grey.shade700)),
Text(
currencyFormatter.format(value),
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: color),
),
],
);
}
Widget _buildCategoryChip(String name, int? categoryId) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: ChoiceChip(
label: Text(name),
selected: false, // Lógica de seleção futura se quiser filtrar
onSelected: (selected) {
// TODO: Implementar filtro por categoria no BLoC
debugPrint('Filtrar por $name (ID: $categoryId)');
},
),
);
}
// --- Diálogos e Telas Auxiliares ---
Future<void> _showAddItemDialog(BuildContext context) async {
final nameController = TextEditingController();
final priceController = TextEditingController();
final qtdController = TextEditingController(text: '1');
final barcodeController = TextEditingController();
Category? selectedCategory;
await showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => BlocBuilder<ShoppingListBloc, ShoppingListState>(
builder: (context, state) {
final categories = (state is ShoppingListLoaded) ? state.categories : <Category>[];
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom + 20,
top: 20,
left: 20,
right: 20,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text('Novo Item', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
const SizedBox(height: 10),
TextField(
controller: nameController,
decoration: const InputDecoration(labelText: 'Nome do Produto'),
),
Row(
children: [
Expanded(
child: TextField(
controller: priceController,
decoration: const InputDecoration(labelText: 'Preço (R\$)'),
keyboardType: TextInputType.number,
),
),
const SizedBox(width: 10),
Expanded(
child: TextField(
controller: qtdController,
decoration: const InputDecoration(labelText: 'Quantidade'),
keyboardType: TextInputType.number,
),
),
],
),
const SizedBox(height: 10),
// Campo de Código de Barras com botão de scanner
Row(
children: [
Expanded(
child: TextField(
controller: barcodeController,
decoration: const InputDecoration(labelText: 'Código de Barras'),
),
),
IconButton(
icon: const Icon(Icons.barcode_reader),
onPressed: () async {
final scannedBarcode = await Navigator.push<String>(
context,
MaterialPageRoute(builder: (c) => const ScannerScreen()),
);
if (scannedBarcode != null) {
barcodeController.text = scannedBarcode;
// Opcional: buscar produto em uma API/DB por este código
}
},
),
],
),
const SizedBox(height: 10),
// Seletor de Categoria
DropdownButtonFormField<Category>(
value: selectedCategory,
hint: const Text('Selecionar Categoria'),
items: categories.map((cat) => DropdownMenuItem(value: cat, child: Text(cat.name))).toList(),
onChanged: (cat) => selectedCategory = cat,
),
const SizedBox(height: 20),
FilledButton(
onPressed: () {
if (nameController.text.isNotEmpty) {
context.read<ShoppingListBloc>().add(
AddShoppingItem(
name: nameController.text,
price: double.tryParse(priceController.text.replaceAll(',', '.')) ?? 0.0,
quantity: int.tryParse(qtdController.text) ?? 1,
barcode: barcodeController.text.isNotEmpty ? barcodeController.text : null,
category: selectedCategory,
),
);
Navigator.pop(context);
}
},
child: const Text('ADICIONAR'),
)
],
),
);
},
),
);
}
void _showManageCategoriesDialog(BuildContext context) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Gerenciar Categorias'),
content: BlocBuilder<ShoppingListBloc, ShoppingListState>(
builder: (context, state) {
if (state is! ShoppingListLoaded) return const CircularProgressIndicator();
final categories = state.categories;
return SizedBox(
width: double.maxFinite,
child: ListView.builder(
shrinkWrap: true,
itemCount: categories.length,
itemBuilder: (context, index) {
final category = categories[index];
return ListTile(
title: Text(category.name),
trailing: IconButton(
icon: const Icon(Icons.delete, color: Colors.redAccent),
onPressed: () {
context.read<ShoppingListBloc>().add(DeleteCategory(category.id!));
},
),
);
},
),
);
},
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Fechar'),
),
FilledButton(
onPressed: () => _showAddCategoryDialog(context),
child: const Text('Adicionar Nova'),
),
],
),
);
}
void _showAddCategoryDialog(BuildContext context) {
final categoryNameController = TextEditingController();
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Adicionar Categoria'),
content: TextField(
controller: categoryNameController,
decoration: const InputDecoration(labelText: 'Nome da Categoria'),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Cancelar')),
FilledButton(
onPressed: () {
if (categoryNameController.text.isNotEmpty) {
context.read<ShoppingListBloc>().add(AddCategory(categoryNameController.text));
Navigator.pop(ctx); // Fecha o dialog de adicionar categoria
Navigator.pop(ctx); // Fecha o dialog de gerenciar categorias
}
},
child: const Text('Salvar'),
),
],
),
);
}
}
📸 Passo 6: Tela de Leitor de Código de Barras
A tela ScannerScreen que usamos no CarControl pode ser reutilizada aqui.
Crie lib/screens/scanner_screen.dart:
import 'package:flutter/material.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
class ScannerScreen extends StatelessWidget {
const ScannerScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Escanear Código de Barras')),
body: MobileScanner(
onDetect: (capture) {
final List<Barcode> barcodes = capture.barcodes;
if (barcodes.isNotEmpty && barcodes.first.rawValue != null) {
final String code = barcodes.first.rawValue!;
Navigator.pop(context, code); // Retorna o código lido
}
},
),
);
}
}
Importante: Não se esqueça de adicionar as permissões de câmera no seu AndroidManifest.xml (Android) e Info.plist (iOS) para o mobile_scanner funcionar!
✅ Conclusão
Com esta segunda parte, transformamos completamente o nosso app de Controle de Compras! Agora ele possui:
-
Organização por Categorias: Gerencie seus produtos de forma mais estruturada.
-
BLoC para Gerenciamento de Estado: Uma arquitetura robusta e escalável para lidar com as interações do usuário e atualizações do banco de dados.
-
Cálculo de Totais: Tenha uma visão clara do custo da sua compra.
-
Leitor de Código de Barras: Agilize a adição de produtos à lista.
Nos próximos artigos, podemos explorar:
-
Autenticação de Usuários: Para listas de compras compartilhadas ou persistência na nuvem.
-
Melhorias na UI/UX: Animações, filtros mais avançados, e designs mais elaborados.
-
Integração com APIs: Para buscar informações de produtos automaticamente.
Continue acompanhando para construir aplicações Flutter cada vez mais complexas e profissionais! 🚀🛒