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:

  1. Banco de Dados Relacional na Nuvem: Criar tabelas shopping_items e categories no Postgres.

  2. Sincronização Realtime: Ver itens aparecendo instantaneamente em outros dispositivos sem precisar atualizar a tela.

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

  1. Crie um projeto em supabase.com.

  2. 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 é:

  1. Usuário clica em “Adicionar” na tela.

  2. BLoC chama _supabaseService.addItem().

  3. O dado vai para a nuvem (Supabase).

  4. O Supabase detecta a inserção e manda um sinal via WebSocket de volta para o app.

  5. O stream no SupabaseService recebe o novo dado.

  6. O BLoC emite um novo estado ShoppingListLoaded.

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

  1. Nuvem Realtime: Implementamos sincronização instantânea usando Supabase.

  2. Relacionamento SQL: Estruturamos os dados de forma relacional (Itens pertencem a Categorias e Usuários).

  3. 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? 🚀☁️

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