Controle de Compras com Flutter: Sincronização Realtime e SQL com Supabase (Parte 4)
Neste artigo, vamos substituir (ou complementar) nosso banco de dados local por uma solução poderosa de Backend-as-a-Service (BaaS) baseada em PostgreSQL: o Supabase.
O que vamos implementar:
-
Banco de Dados Relacional na Nuvem: Criar tabelas
shopping_itemsecategoriesno Postgres. -
Sincronização Realtime: Ver itens aparecendo instantaneamente em outros dispositivos sem precisar atualizar a tela.
-
Segurança (RLS): Garantir que cada usuário veja apenas os seus próprios dados usando Row Level Security.
🛠️ Passo 1: Configurando o Supabase
Antes de tocar no código Flutter, precisamos preparar o backend.
-
Crie um projeto em supabase.com.
-
Vá para o SQL Editor e rode o seguinte script para criar nossas tabelas:
-- Cria a tabela de Categorias
create table public.categories (
id bigint generated by default as identity primary key,
name text not null,
created_at timestamp with time zone default timezone('utc'::text, now()) not null,
user_id uuid references auth.users not null -- Relacionamento com o usuário logado
);
-- Cria a tabela de Itens de Compra
create table public.shopping_items (
id bigint generated by default as identity primary key,
name text not null,
price numeric default 0,
quantity integer default 1,
is_bought boolean default false,
created_at timestamp with time zone default timezone('utc'::text, now()) not null,
category_id bigint references public.categories, -- Relacionamento SQL (Foreign Key)
user_id uuid references auth.users not null
);
-- Habilita Realtime para essas tabelas
alter publication supabase_realtime add table public.shopping_items;
alter publication supabase_realtime add table public.categories;
🛡️ Configurando Row Level Security (RLS)
Isso é crucial! Sem RLS, qualquer usuário poderia ver a lista de compras de todo mundo.
Rode este script SQL para ativar a segurança:
-- Ativa RLS nas tabelas alter table public.categories enable row level security; alter table public.shopping_items enable row level security; -- Política: Usuários só veem seus próprios itens create policy "Users can see their own items" on public.shopping_items for select using (auth.uid() = user_id); -- Política: Usuários só inserem itens com seu próprio ID create policy "Users can insert their own items" on public.shopping_items for insert with check (auth.uid() = user_id); -- (Repita lógica similar para Update/Delete e para a tabela categories)
📦 Passo 2: Dependências Flutter
Adicione o SDK do Supabase no pubspec.yaml:
dependencies: supabase_flutter: ^2.3.0 # Remova ou mantenha o isar dependendo da estratégia (aqui vamos focar no Supabase puro)
Inicialize no main.dart:
import 'package:supabase_flutter/supabase_flutter.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Supabase.initialize(
url: 'SUA_URL_DO_SUPABASE',
anonKey: 'SUA_ANON_KEY_DO_SUPABASE',
);
runApp(const MyApp());
}
🔄 Passo 3: O Serviço de Supabase (Realtime)
Vamos criar um SupabaseService que substitui nosso antigo IsarService. A grande diferença aqui é que o Supabase retorna uma Stream de dados em tempo real.
Crie lib/services/supabase_service.dart:
import 'package:supabase_flutter/supabase_flutter.dart';
class SupabaseService {
final _client = Supabase.instance.client;
// Stream de Itens em Tempo Real
Stream<List<Map<String, dynamic>>> getShoppingListStream() {
return _client
.from('shopping_items')
.stream(primaryKey: ['id']) // Necessário para o stream funcionar
.order('created_at', ascending: false);
}
// Adicionar Item (com relacionamento de Usuário automático)
Future<void> addItem(String name, double price, int quantity, int? categoryId) async {
final user = _client.auth.currentUser;
if (user == null) throw Exception('Usuário não logado');
await _client.from('shopping_items').insert({
'name': name,
'price': price,
'quantity': quantity,
'is_bought': false,
'user_id': user.id, // Vincula ao usuário logado
'category_id': categoryId, // Relacionamento SQL
});
}
// Atualizar Status (Comprado/Não Comprado)
Future<void> toggleStatus(int id, bool currentValue) async {
await _client.from('shopping_items').update({
'is_bought': !currentValue,
}).eq('id', id);
}
// Deletar Item
Future<void> deleteItem(int id) async {
await _client.from('shopping_items').delete().eq('id', id);
}
// Buscar Categorias (Pode ser stream ou future)
Future<List<Map<String, dynamic>>> getCategories() async {
return await _client.from('categories').select();
}
}
🧠 Passo 4: Atualizando o BLoC
Agora precisamos adaptar nosso ShoppingListBloc para consumir o stream do Supabase em vez do Isar local.
Atualize lib/blocs/shopping_list/shopping_list_bloc.dart:
// ... imports
class ShoppingListBloc extends Bloc<ShoppingListEvent, ShoppingListState> {
final SupabaseService _supabaseService;
StreamSubscription? _itemsSubscription;
ShoppingListBloc(this._supabaseService) : super(ShoppingListLoading()) {
// ... mapeamento de eventos (Add, Toggle, Delete)
// Inicia a escuta do Realtime
_startListening();
}
void _startListening() {
_itemsSubscription?.cancel();
_itemsSubscription = _supabaseService.getShoppingListStream().listen(
(data) {
// Converte o Map do Supabase para nosso Model (ShoppingItem)
final items = data.map((e) => ShoppingItem.fromMap(e)).toList();
// Emite o estado atualizado automaticamente!
add(UpdateListFromStream(items));
},
onError: (error) => add(ShoppingListError(error.toString())),
);
}
// ... Resto da lógica de adicionar/remover chama _supabaseService agora
}
Nota: Você precisará atualizar seu Model ShoppingItem para ter um método fromMap que aceite o JSON do Supabase.
Aqui está o código completo do ShoppingItem atualizado para funcionar com o Supabase:
1. O Modelo Category
Primeiro, precisamos do modelo da categoria, pois ele será usado dentro do item.
class Category {
final int? id;
final String name;
Category({this.id, required this.name});
// Converte JSON (Supabase) -> Objeto Dart
factory Category.fromMap(Map<String, dynamic> map) {
return Category(
id: map['id'] as int?,
name: map['name'] ?? '',
);
}
// Converte Objeto Dart -> JSON (Supabase)
Map<String, dynamic> toMap() {
return {
// Não enviamos o ID na criação, pois o banco gera
if (id != null) 'id': id,
'name': name,
};
}
}
2. O Modelo ShoppingItem
Agora, o modelo principal. Note como tratamos o campo categories que pode vir aninhado se fizermos um join.
class ShoppingItem {
final int? id;
final String name;
final double price;
final int quantity;
final bool isBought;
final int? categoryId;
final Category? category; // Objeto aninhado (opcional)
ShoppingItem({
this.id,
required this.name,
required this.price,
this.quantity = 1,
this.isBought = false,
this.categoryId,
this.category,
});
// --- O MÉTODO MÁGICO (fromMap) ---
// Transforma o JSON do Supabase (snake_case) em nossa classe
factory ShoppingItem.fromMap(Map<String, dynamic> map) {
return ShoppingItem(
id: map['id'] as int?,
name: map['name'] ?? '',
// O banco pode retornar int (10) ou double (10.5), por isso usamos num
price: (map['price'] as num?)?.toDouble() ?? 0.0,
quantity: map['quantity'] as int? ?? 1,
isBought: map['is_bought'] as bool? ?? false, // Note o snake_case aqui
categoryId: map['category_id'] as int?,
// Se a query do Supabase incluir a categoria (join), ela virá como um Map aqui
category: map['categories'] != null
? Category.fromMap(map['categories'])
: null,
);
}
// --- PARA ENVIAR AO BANCO (toMap) ---
// Transforma nossa classe em JSON para o Supabase
Map<String, dynamic> toMap() {
return {
// Removemos 'id' se for null (criação), o banco gera automático
if (id != null) 'id': id,
'name': name,
'price': price,
'quantity': quantity,
'is_bought': isBought,
'category_id': categoryId,
// Não enviamos o objeto 'category' completo, apenas o ID da relação
};
}
// Um copyWith ajuda muito no BLoC para alterar apenas um campo
ShoppingItem copyWith({
int? id,
String? name,
double? price,
int? quantity,
bool? isBought,
int? categoryId,
Category? category,
}) {
return ShoppingItem(
id: id ?? this.id,
name: name ?? this.name,
price: price ?? this.price,
quantity: quantity ?? this.quantity,
isBought: isBought ?? this.isBought,
categoryId: categoryId ?? this.categoryId,
category: category ?? this.category,
);
}
}
Como usar isso no Serviço?
Quando você buscar os dados no SupabaseService, você precisará pedir para o Supabase trazer a categoria junto. O “truque” está na string do select.
// No SupabaseService.dart
Stream<List<ShoppingItem>> getShoppingListStream() {
return _client
.from('shopping_items')
// O select é poderoso:
// '*' traz todos os campos do item
// 'categories(*)' faz um JOIN e traz os campos da tabela categories aninhados!
.stream(primaryKey: ['id'])
.order('created_at')
.map((data) {
// data é uma List<Map<String, dynamic>>
// Convertemos cada Map para um ShoppingItem usando nosso novo método
return data.map((itemJson) => ShoppingItem.fromMap(itemJson)).toList();
});
}
Dessa forma, seu BLoC receberá objetos ShoppingItem prontinhos, com a Categoria já preenchida dentro dele, tudo tipado e seguro! 🚀
📱 Passo 5: Testando o Realtime (Magia Acontece)
Não precisamos mudar quase nada na UI (HomeScreen), pois ela já reage aos estados do BLoC!
O fluxo agora é:
-
Usuário clica em “Adicionar” na tela.
-
BLoC chama
_supabaseService.addItem(). -
O dado vai para a nuvem (Supabase).
-
O Supabase detecta a inserção e manda um sinal via WebSocket de volta para o app.
-
O
streamnoSupabaseServicerecebe o novo dado. -
O BLoC emite um novo estado
ShoppingListLoaded. -
A tela atualiza sozinha.
Teste Prático: Abra o app em dois simuladores (ou um simulador e um celular real) logados na mesma conta. Adicione um item em um dispositivo. Ele aparecerá instantaneamente no outro. Risque um item como “comprado”, e ele será riscado no outro dispositivo em milissegundos.
✅ Conclusão
Nesta Parte 3, demos um salto gigante de complexidade e utilidade:
-
Nuvem Realtime: Implementamos sincronização instantânea usando Supabase.
-
Relacionamento SQL: Estruturamos os dados de forma relacional (Itens pertencem a Categorias e Usuários).
-
Segurança Robusta: Usamos RLS (Row Level Security) para garantir que os dados de um usuário jamais vazem para outro, direto na camada do banco de dados.
Próximos Passos: Agora que temos um backend poderoso, podemos pensar em funcionalidades sociais avançadas:
-
Compartilhamento de Listas: Permitir que o “Marido” convide a “Esposa” para editar a mesma lista (ajustando as regras de RLS).
-
Push Notifications: Avisar quando alguém adicionar um item urgente na lista.
O app está ficando profissional! O que achou da facilidade do Supabase em comparação a configurar um backend tradicional? 🚀☁️