Controle de Compras com Flutter: UI Moderna, Integração com APIs e Autenticação (Parte 3)
Neste artigo, vamos elevar o nível do nosso aplicativo. Chega de telas cinzas e dados manuais!
O que vamos construir:
-
UI/UX de Alto Nível: Cards coloridos, animações fluidas e filtros avançados.
-
Inteligência de Mercado: Ao escanear um código de barras, o app buscará automaticamente o nome e a foto do produto na internet (usando a API Open Food Facts).
-
Segurança: Implementação de Autenticação com Firebase para proteger os dados.
📦 Passo 1: Novas Dependências
Vamos adicionar pacotes para requisições HTTP, animações e fontes bonitas.
No seu pubspec.yaml:
dependencies: # ... dependências anteriores (isar, bloc, etc) # HTTP para API http: ^1.2.0 # UI e Animações flutter_animate: ^4.5.0 google_fonts: ^6.1.0 cached_network_image: ^3.3.1 # Para imagens da API # Autenticação (Firebase) firebase_core: ^2.24.2 firebase_auth: ^4.16.0
Rode flutter pub get.
🌐 Passo 2: Integração com API (Open Food Facts)
Não queremos digitar o nome de todo produto. Vamos criar um serviço que busca os dados automaticamente pelo código de barras.
Crie lib/services/product_lookup_service.dart:
import 'dart:convert';
import 'package:http/http.dart' as http;
class ProductLookupService {
// API Gratuita e Open Source
static const String _baseUrl = 'https://world.openfoodfacts.org/api/v0/product';
Future<Map<String, dynamic>?> lookupProduct(String barcode) async {
try {
final response = await http.get(Uri.parse('$_baseUrl/$barcode.json'));
if (response.statusCode == 200) {
final data = json.decode(response.body);
if (data['status'] == 1) {
final product = data['product'];
return {
'name': product['product_name'] ?? '',
'image_url': product['image_front_small_url'],
'brands': product['brands'] ?? '',
};
}
}
} catch (e) {
print('Erro ao buscar produto: $e');
}
return null;
}
}
Agora, precisamos atualizar nosso BLoC ou a UI para usar isso. Para simplificar, usaremos direto no modal de adicionar produto (passo 4).
🎨 Passo 3: UI Moderna e Colorida
Vamos abandonar o ListTile padrão e criar um Card personalizado, com cores que mudam baseadas na categoria e animações de entrada.
Crie lib/widgets/shopping_item_card.dart:
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../collections/shopping_item.dart';
class ShoppingItemCard extends StatelessWidget {
final ShoppingItem item;
final VoidCallback onToggle;
final VoidCallback onDelete;
const ShoppingItemCard({
super.key,
required this.item,
required this.onToggle,
required this.onDelete,
});
@override
Widget build(BuildContext context) {
// Cor dinâmica baseada se foi comprado ou não
final bgColor = item.isBought ? Colors.grey.shade200 : Colors.white;
final textColor = item.isBought ? Colors.grey : Colors.black87;
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(16),
boxShadow: [
if (!item.isBought)
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onToggle,
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Row(
children: [
// Imagem do Produto (se houver, vinda da API) ou Ícone
Container(
width: 50,
height: 50,
decoration: BoxDecoration(
color: Colors.deepPurple.shade50,
borderRadius: BorderRadius.circular(12),
),
child: const Icon(Icons.shopping_bag_outlined, color: Colors.deepPurple),
),
const SizedBox(width: 16),
// Textos
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.name,
style: GoogleFonts.poppins(
fontSize: 16,
fontWeight: FontWeight.w600,
decoration: item.isBought ? TextDecoration.lineThrough : null,
color: textColor,
),
),
if (item.category.value != null)
Container(
margin: const EdgeInsets.only(top: 4),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(8),
),
child: Text(
item.category.value!.name,
style: const TextStyle(fontSize: 10, color: Colors.blue),
),
),
],
),
),
// Preço e Qtd
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'R\$ ${(item.price * item.quantity).toStringAsFixed(2)}',
style: GoogleFonts.poppins(
fontWeight: FontWeight.bold,
color: item.isBought ? Colors.grey : Colors.green,
),
),
Text('${item.quantity}un', style: TextStyle(fontSize: 12, color: Colors.grey)),
],
),
],
),
),
),
),
),
).animate().fadeIn().slideX(begin: 0.2, end: 0); // Animação de entrada
}
}
🔐 Passo 4: Autenticação (Firebase Auth)
Para ter listas na nuvem, precisamos saber quem é o usuário. Vamos configurar um login simples.
Nota: Certifique-se de ter configurado o projeto no Console do Firebase e baixado o
google-services.json(Android) ouGoogleService-Info.plist(iOS).
Crie lib/services/auth_service.dart:
import 'package:firebase_auth/firebase_auth.dart';
class AuthService {
final FirebaseAuth _auth = FirebaseAuth.instance;
Stream<User?> get user => _auth.authStateChanges();
// Login Simples (Anônimo para teste rápido ou Email/Senha)
Future<void> signInAnon() async {
await _auth.signInAnonymously();
}
Future<void> signOut() async {
await _auth.signOut();
}
String? get userId => _auth.currentUser?.uid;
}
Agora, precisamos criar um Wrapper no main.dart para decidir qual tela mostrar.
Atualize lib/main.dart:
import 'package:firebase_core/firebase_core.dart';
// ... outros imports
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(); // Inicializa Firebase
final isarService = IsarService();
runApp(
BlocProvider(
create: (context) => ShoppingListBloc(isarService),
child: const MyApp(),
),
);
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
// ... theme config
home: const AuthWrapper(), // Decide a tela inicial
);
}
}
class AuthWrapper extends StatelessWidget {
const AuthWrapper({super.key});
@override
Widget build(BuildContext context) {
return StreamBuilder<User?>(
stream: FirebaseAuth.instance.authStateChanges(),
builder: (context, snapshot) {
if (snapshot.hasData) {
return const HomeScreen(); // Usuário logado
}
return const LoginScreen(); // Usuário deslogado
},
);
}
}
🚀 Passo 5: A Evolução da Home e Filtros Avançados
Vamos juntar a API no momento da adição e criar filtros visuais na Home.
Na HomeScreen (dentro do método _showAddItemDialog):
Modifique a lógica do botão de scanner para chamar a API:
IconButton(
icon: const Icon(Icons.barcode_reader),
onPressed: () async {
// 1. Escaneia
final scannedBarcode = await Navigator.push<String>(
context,
MaterialPageRoute(builder: (c) => const ScannerScreen()),
);
if (scannedBarcode != null) {
barcodeController.text = scannedBarcode;
// 2. Busca na API (Loading visual opcional aqui)
final productData = await ProductLookupService().lookupProduct(scannedBarcode);
if (productData != null) {
// 3. Preenche automaticamente os campos!
nameController.text = productData['name'];
// Se tiver URL de imagem, poderia salvar no banco também
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Produto encontrado: ${productData['name']}')),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Produto não encontrado na base de dados.')),
);
}
}
},
),
Adicionando Filtros Visuais na Home:
Substitua a lista de Chips simples por algo mais elaborado:
// Widget de Filtros
SizedBox(
height: 60,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: state.categories.length + 1,
itemBuilder: (ctx, i) {
final isSelected = _selectedCategoryIndex == i; // Você precisará gerenciar esse estado na tela
return GestureDetector(
onTap: () {
setState(() => _selectedCategoryIndex = i);
// Disparar evento do Bloc para filtrar
},
child: AnimatedContainer(
duration: 300.ms,
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
padding: const EdgeInsets.symmetric(horizontal: 20),
alignment: Alignment.center,
decoration: BoxDecoration(
color: isSelected ? Colors.deepPurple : Colors.white,
borderRadius: BorderRadius.circular(30),
border: Border.all(color: Colors.deepPurple.withOpacity(0.2)),
boxShadow: isSelected ? [
BoxShadow(color: Colors.deepPurple.withOpacity(0.4), blurRadius: 8, offset: const Offset(0, 4))
] : [],
),
child: Text(
i == 0 ? "Todos" : state.categories[i-1].name,
style: TextStyle(
color: isSelected ? Colors.white : Colors.black87,
fontWeight: FontWeight.bold
),
),
),
);
},
),
),
✅ Conclusão e Futuro
Nesta Parte 3, transformamos um app funcional em uma experiência completa:
-
UX Profissional: Usamos
flutter_animateegoogle_fontspara criar cards que dão prazer de usar. -
Automação: Integramos a API Open Food Facts, poupando tempo de digitação do usuário, documentação do uso da api (documentacao)
-
Segurança: Preparamos o terreno com Autenticação.
Para a próxima etapa (Parte 4): Podemos focar em Sincronização na Nuvem com Supabase.
Agora que identificamos o usuário, podemos evoluir nosso armazenamento para a nuvem. Em vez do Firestore, usaremos o Supabase (uma alternativa Open Source poderosa baseada em PostgreSQL).
Isso trará grandes vantagens:
-
Relacionamentos SQL: O Supabase lida muito bem com relações (Categorias <-> Produtos), algo que bancos NoSQL como Firestore dificultam um pouco.
-
Realtime: Assim como o Isar local, o Supabase tem suporte a realtime, permitindo que se você adicionar um item no seu celular, ele apareça instantaneamente no celular da sua esposa/marido.
-
Row Level Security (RLS): Garantiremos que cada usuário veja apenas os seus próprios dados (ou dados compartilhados com a família).
O que acha dessa direção usando PostgreSQL e Supabase? 🚀