Módulo 12 — Notificações Push com Firebase Cloud Messaging

Você chegou a um módulo que resolve um problema muito concreto do aplicativo de delivery: como manter o cliente informado sobre o que está acontecendo com o seu pedido? Até agora, o usuário faz o pedido, recebe a confirmação na tela e… precisa abrir o aplicativo manualmente para saber se o pedido foi confirmado pelo restaurante, se está sendo preparado ou se saiu para entrega. Isso é uma experiência ruim. Qualquer usuário que tenha pedido comida por um aplicativo moderno sabe que as notificações de atualização de status são parte fundamental da experiência — e é exatamente isso que você vai implementar neste módulo.

O Firebase Cloud Messaging — FCM — é o serviço de mensageria da Google que atua como intermediário entre o seu servidor backend (as Lambda Functions que você criou no Módulo 10) e os dispositivos dos usuários do delivery. Ele é gratuito, escala automaticamente para milhões de dispositivos e funciona em Android, iOS e web. Neste módulo, você vai configurar o FCM no projeto Firebase, integrar os pacotes firebase_core e firebase_messaging no Flutter, implementar o recebimento de notificações nos três estados em que a aplicação pode se encontrar — foreground, background e terminated — e configurar uma Lambda Function para enviar notificações quando o status de um pedido é atualizado.

Estude cada seção na ordem em que estão apresentadas, pois a dependência entre elas é especialmente forte aqui: você não consegue receber notificações sem antes configurar o Firebase, não consegue enviar notificações do backend sem antes registrar o token FCM do dispositivo, e não consegue navegar para a tela correta ao tocar em uma notificação sem antes entender como o sistema de mensageria entrega os dados ao aplicativo.


Seção 1 — Arquitetura de Notificações Push: Entendendo o Sistema

Antes de escrever uma única linha de código, você precisa entender como o sistema de notificações push funciona em nível arquitetural. Muitos desenvolvedores implementam notificações de forma mecânica — seguindo um tutorial passo a passo — sem compreender por que cada peça existe. Esse entendimento superficial frequentemente leva a bugs difíceis de diagnosticar, como notificações que chegam no emulador mas não no dispositivo físico, ou notificações que funcionam no foreground mas desaparecem no background.

A premissa fundamental das notificações push é que, em sistemas operacionais móveis, os aplicativos não podem manter conexões de rede abertas permanentemente enquanto estão em background — isso consumiria bateria de forma inaceitável. Ao mesmo tempo, o usuário precisa ser notificado sobre eventos relevantes mesmo quando não está usando o aplicativo no momento. A solução adotada tanto pelo Android quanto pelo iOS é delegar a responsabilidade de manter uma conexão persistente com um serviço de mensageria ao próprio sistema operacional.

O sistema operacional mantém uma única conexão TCP persistente e de baixo consumo de energia com o servidor de mensageria da plataforma. No Android, esse servidor é o Firebase Cloud Messaging (FCM). No iOS, é o Apple Push Notification Service (APNs). Quando o seu backend quer enviar uma notificação para um usuário, ele não se conecta diretamente ao dispositivo — isso seria impossível, já que o dispositivo pode estar em uma rede privada atrás de um NAT, com endereço IP dinâmico, potencialmente offline naquele momento. Em vez disso, o backend envia a mensagem ao FCM, que a armazena (por um período configurável) e a entrega ao dispositivo quando a conexão estiver disponível.

sequenceDiagram
    participant L as Lambda AWS
    participant F as Firebase FCM
    participant OS as Sistema Operacional Android
    participant A as Aplicativo Flutter

    L->>F: POST /messages:send<br/>(token FCM + payload)
    F->>F: armazena mensagem
    OS->>F: conexão TCP persistente (keep-alive)
    F->>OS: entrega mensagem
    OS->>A: dispara handler (foreground/background/terminated)
    A->>A: processa payload e exibe notificação

O Token FCM: O Endereço do Dispositivo

Cada instalação do aplicativo em um dispositivo específico recebe um token FCM único. Pense nele como o endereço de entrega da notificação: quando o backend quer notificar um usuário específico, ele envia a mensagem ao FCM indicando o token daquele dispositivo, e o FCM sabe para qual dispositivo encaminhar a mensagem.

O token FCM tem características importantes que você precisa conhecer. Ele é específico da combinação dispositivo + instalação do aplicativo: se o usuário desinstala e reinstala o aplicativo, um novo token é gerado. Se o usuário faz backup do dispositivo e restaura em outro aparelho, o token é diferente. Se o aplicativo é atualizado em circunstâncias específicas, o token pode mudar. Por isso, o backend precisa armazenar sempre o token mais recente de cada dispositivo — e o aplicativo Flutter precisa enviar o token atualizado ao backend toda vez que ele mudar.

Tipos de Mensagem FCM

O FCM suporta dois tipos de conteúdo em uma mensagem, que podem ser usados separadamente ou combinados.

O primeiro tipo é a mensagem de notificação (notification message). Ela contém um título e um corpo que o sistema operacional usa para construir e exibir automaticamente uma notificação na bandeja do sistema. Quando o aplicativo está em background ou terminated e uma mensagem de notificação chega, o sistema operacional a exibe sem precisar acordar o aplicativo. Quando o usuário toca na notificação, o aplicativo é iniciado (ou trazido para foreground) e o handler de toque é disparado.

O segundo tipo é a mensagem de dados (data message). Ela contém um mapa de chave-valor com informações arbitrárias destinadas ao código do aplicativo. Mensagens de dados não são exibidas automaticamente pelo sistema operacional — cabe ao código do aplicativo decidir o que fazer com elas. Isso oferece mais controle, mas também mais responsabilidade: em background, o handler de mensagens de dados precisa ser uma função isolada (top-level function), pois o Flutter pode não estar totalmente inicializado.

Na prática, a abordagem mais robusta é usar mensagens combinadas: o campo notification para que o sistema operacional exiba a notificação automaticamente, e o campo data para carregar informações estruturadas que o aplicativo usará para navegar para a tela correta quando o usuário tocar na notificação.

A Relação com o APNs para iOS

No Android, o FCM entrega as mensagens diretamente ao dispositivo através da conexão persistente. No iOS, o FCM atua como intermediário: ele recebe a mensagem do backend e a repassa ao APNs da Apple, que então a entrega ao dispositivo. Isso significa que, para notificações funcionarem no iOS, o projeto Firebase precisa ser configurado com o certificado APNs ou a chave de autenticação APNs (chave .p8), que você obtém na conta de desenvolvedor Apple. Como este módulo foca no Android — a plataforma que você está usando ao longo de toda a disciplina — a configuração específica do iOS não será detalhada aqui, mas é importante que você saiba que ela existe e que a arquitetura é ligeiramente diferente nessa plataforma.


Seção 2 — Configurando o Projeto Firebase

A configuração do Firebase envolve dois lados: o console web do Firebase (onde você registra o projeto e o aplicativo) e o projeto Flutter (onde você integra os pacotes e os arquivos de configuração). É uma configuração que precisa ser feita uma única vez, mas cada passo precisa ser executado corretamente — um erro na configuração do Firebase é uma das causas mais comuns de bugs difíceis de diagnosticar no início de um projeto.

Criando o Projeto no Console Firebase

Acesse console.firebase.google.com e faça login com a mesma conta Google que você usa para a AWS (ou qualquer conta Google pessoal — o Firebase e a AWS são plataformas independentes). Clique em “Adicionar projeto” e dê ao projeto o nome delivery-app. Na etapa de configuração do Google Analytics, você pode habilitá-lo ou não — para os fins deste módulo, a escolha não afeta o funcionamento das notificações push.

Após a criação do projeto, você estará no painel principal do Firebase. Clique no ícone do Android para registrar o aplicativo Android. Você precisará fornecer o nome do pacote Android do seu aplicativo Flutter, que está definido no arquivo android/app/build.gradle no campo applicationId. Por padrão em projetos Flutter criados recentemente, ele tem o formato com.example.{nome_do_projeto}, mas para o projeto de delivery você deve ter definido algo como com.delivery.app durante a configuração inicial no Módulo 01.

Após informar o nome do pacote, faça o download do arquivo google-services.json e coloque-o exatamente na pasta android/app/ do projeto Flutter. Esse arquivo contém as credenciais que o SDK do Firebase usa para se comunicar com o backend do Google — o Firebase verifica se o arquivo está presente e correto durante a compilação.

Configurando o Gradle do Android

O Firebase no Android funciona como um plugin do Gradle. Você precisa modificar dois arquivos de configuração do Gradle.

No arquivo android/build.gradle (o de nível de projeto, não o do módulo app), adicione o classpath do plugin Google Services nas dependências:

buildscript {
    dependencies {
        // adicionar esta linha
        classpath 'com.google.gms:google-services:4.4.0'
    }
}

No arquivo android/app/build.gradle (o do módulo app), adicione a aplicação do plugin ao final do arquivo:

// adicionar ao final do arquivo
apply plugin: 'com.google.gms.google-services'

Se você estiver usando a nova sintaxe de plugins declarativos do Gradle (projetos criados com Flutter 3.16 ou posterior), a configuração é ligeiramente diferente. Consulte a documentação oficial do Firebase FlutterFire para a sintaxe correspondente à sua versão do Flutter.

Adicionando os Pacotes Flutter

Adicione as dependências ao pubspec.yaml:

dependencies:
  firebase_core: ^4.4.0
  firebase_messaging: ^16.1.1

Execute flutter pub get para baixar os pacotes. Em seguida, execute flutter run para verificar que a configuração está correta. Se o aplicativo iniciar sem erros, o Firebase está configurado corretamente no lado do Android.

Configurando Permissões no AndroidManifest

Para que o aplicativo possa receber notificações em background e iniciar atividades a partir de notificações, adicione as seguintes permissões ao android/app/src/main/AndroidManifest.xml:

<manifest ...>
    <!-- Permissão para exibir notificações (necessária a partir do Android 13) -->
    <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>

    <application ...>
        <!-- Serviço de mensageria do Firebase -->
        <service
            android:name="com.google.firebase.messaging.FirebaseMessagingService"
            android:exported="false">
            <intent-filter>
                <action android:name="com.google.firebase.MESSAGING_EVENT"/>
            </intent-filter>
        </service>
    </application>
</manifest>

A partir do Android 13 (API level 33), exibir notificações requer uma permissão em tempo de execução — POST_NOTIFICATIONS — que precisa ser solicitada ao usuário, similar à permissão de câmera. O pacote firebase_messaging facilita esse processo através do método requestPermission(), que você implementará na próxima seção.


Seção 3 — Inicializando o Firebase e Solicitando Permissão

O Firebase precisa ser inicializado antes de qualquer uso dos seus serviços. A inicialização é assíncrona e deve acontecer antes de qualquer outra chamada ao SDK. O local correto para isso é na função main() do projeto Flutter, antes de chamar runApp().

Inicialização no main.dart

A inicialização do Firebase e a configuração do handler de background precisam acontecer na função main(). O handler de mensagens em background tem um requisito especial: ele deve ser uma função de nível superior (top-level function) — não pode ser um método de uma classe, não pode ser uma closure. Além disso, precisa ser anotada com @pragma('vm:entry-point') para garantir que o compilador Dart a mantenha mesmo em builds de release com tree-shaking:

import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/material.dart';
import 'firebase_options.dart'; // gerado automaticamente pelo flutterfire CLI

/// Handler para mensagens recebidas enquanto o app está em background ou encerrado.
/// OBRIGATÓRIO: deve ser uma top-level function (fora de qualquer classe).
/// OBRIGATÓRIO: deve ser anotada com @pragma para sobreviver ao tree-shaking.
@pragma('vm:entry-point')
Future<void> _handlerMensagemBackground(RemoteMessage mensagem) async {
  // O Firebase precisa ser inicializado mesmo no isolate de background
  await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);

  print('Mensagem recebida em background:');
  print('  Título: ${mensagem.notification?.title}');
  print('  Dados: ${mensagem.data}');

  // Neste handler você não pode usar BuildContext, Provider ou Navigator.
  // Você pode salvar os dados localmente (SharedPreferences, SQLite) para
  // que o app os processe quando for aberto pelo usuário.
}

Future<void> main() async {
  // Garante que os bindings do Flutter estão inicializados antes de
  // operações assíncronas no main()
  WidgetsFlutterBinding.ensureInitialized();

  // Inicializa o Firebase com as opções da plataforma atual
  await Firebase.initializeApp(
    options: DefaultFirebaseOptions.currentPlatform,
  );

  // Registra o handler de mensagens em background.
  // Deve ser chamado antes de qualquer outra configuração do FCM.
  FirebaseMessaging.onBackgroundMessage(_handlerMensagemBackground);

  runApp(const DeliveryApp());
}
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/material.dart';
import 'firebase_options.dart';

@pragma('vm:entry-point')
Future<void> _bgHandler(RemoteMessage msg) async {
  await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
  // processamento mínimo — sem BuildContext, sem Provider
}

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
  FirebaseMessaging.onBackgroundMessage(_bgHandler);
  runApp(const DeliveryApp());
}

O Arquivo firebase_options.dart

O arquivo firebase_options.dart referenciado no código acima é gerado automaticamente pela ferramenta flutterfire configure, que faz parte do FlutterFire CLI. Para instalar o FlutterFire CLI e gerar o arquivo:

dart pub global activate flutterfire_cli
flutterfire configure --project=delivery-app

O comando flutterfire configure lê o arquivo google-services.json que você já colocou na pasta android/app/ e gera o firebase_options.dart com as configurações correspondentes. Esse arquivo não deve ser modificado manualmente. Se as configurações do Firebase mudarem (por exemplo, se você adicionar um novo aplicativo ao projeto), basta executar flutterfire configure novamente.

Solicitando Permissão para Notificações

Após a inicialização do Firebase, você precisa solicitar ao usuário a permissão para enviar notificações. Essa solicitação deve acontecer em um momento contextualmente adequado na experiência do usuário — não logo após o login, onde o usuário ainda não entendeu o valor que as notificações trarão. O momento ideal no aplicativo de delivery é logo após o usuário confirmar o primeiro pedido: “Para te avisar quando seu pedido estiver pronto, preciso de permissão para enviar notificações.”

O código de solicitação de permissão deve fazer parte do FirebaseNotificacaoServico, um serviço que você vai criar na camada de infraestrutura:

import 'package:firebase_messaging/firebase_messaging.dart';

class FirebaseNotificacaoServico {
  final FirebaseMessaging _messaging;

  FirebaseNotificacaoServico({FirebaseMessaging? messaging})
      : _messaging = messaging ?? FirebaseMessaging.instance;

  /// Solicita permissão para enviar notificações ao usuário.
  /// No Android 13+, exibe o diálogo de permissão do sistema.
  /// No Android anterior ao 13, sempre retorna [AuthorizationStatus.authorized].
  /// Retorna true se o usuário concedeu permissão.
  Future<bool> solicitarPermissao() async {
    final NotificationSettings configuracoes = await _messaging.requestPermission(
      alert: true,      // notificações na bandeja
      badge: true,      // badge no ícone do app
      sound: true,      // som de notificação
      announcement: false,
      carPlay: false,
      criticalAlert: false,
      provisional: false,
    );

    final AuthorizationStatus status = configuracoes.authorizationStatus;

    if (status == AuthorizationStatus.authorized) {
      print('Permissão de notificação concedida');
      return true;
    } else if (status == AuthorizationStatus.provisional) {
      // iOS: notificações silenciosas sem diálogo de confirmação
      print('Permissão provisional concedida');
      return true;
    } else {
      print('Permissão de notificação negada: $status');
      return false;
    }
  }

  /// Verifica o status atual da permissão sem solicitá-la.
  Future<AuthorizationStatus> verificarStatusPermissao() async {
    final NotificationSettings config = await _messaging.getNotificationSettings();
    return config.authorizationStatus;
  }
}
import 'package:firebase_messaging/firebase_messaging.dart';

class FirebaseNotificacaoServico {
  FirebaseNotificacaoServico({FirebaseMessaging? messaging})
      : _messaging = messaging ?? FirebaseMessaging.instance;

  final FirebaseMessaging _messaging;

  Future<bool> solicitarPermissao() async {
    final s = await _messaging.requestPermission(
      alert: true, badge: true, sound: true,
    );
    return switch (s.authorizationStatus) {
      AuthorizationStatus.authorized || AuthorizationStatus.provisional => true,
      _ => false,
    };
  }

  Future<AuthorizationStatus> verificarStatusPermissao() async =>
      (await _messaging.getNotificationSettings()).authorizationStatus;
}

Seção 4 — Obtendo e Gerenciando o Token FCM

O token FCM é a peça central que conecta um dispositivo físico ao sistema de notificações. Sem o token, o backend não tem como endereçar uma notificação a um usuário específico. Gerenciar o token corretamente — obtê-lo, enviá-lo ao backend associado ao usuário e detectar quando ele muda — é uma das partes mais importantes da implementação de notificações push.

Obtendo o Token Atual

O token FCM do dispositivo é obtido de forma assíncrona através do método getToken(). Esse método retorna o token se o dispositivo já tiver um token válido, ou solicita um novo ao FCM se necessário. O token é uma string longa e opaca — você não precisa interpretá-la, apenas armazená-la e enviá-la ao backend.

import 'package:firebase_messaging/firebase_messaging.dart';

class FirebaseNotificacaoServico {
  // ... (continuação da classe anterior)

  /// Obtém o token FCM do dispositivo atual.
  /// Retorna null se o dispositivo não suportar notificações push
  /// (por exemplo, emuladores sem Google Play Services).
  Future<String?> obterToken() async {
    try {
      final String? token = await _messaging.getToken();
      if (token != null) {
        print('Token FCM obtido: ${token.substring(0, 20)}...'); // log parcial por segurança
      }
      return token;
    } catch (e) {
      print('Erro ao obter token FCM: $e');
      return null;
    }
  }

  /// Registra um listener que é chamado automaticamente quando o token FCM
  /// é renovado. Isso acontece quando o usuário restaura o app em um novo
  /// dispositivo, quando o Google Play Services atualiza o token, etc.
  void ouvirRenovacaoToken(void Function(String novoToken) onTokenRenovado) {
    _messaging.onTokenRefresh.listen(
      (String novoToken) {
        print('Token FCM renovado');
        onTokenRenovado(novoToken);
      },
      onError: (Object erro) {
        print('Erro ao ouvir renovação de token: $erro');
      },
    );
  }
}
  Future<String?> obterToken() async {
    try {
      return await _messaging.getToken();
    } catch (_) {
      return null;
    }
  }

  void ouvirRenovacaoToken(void Function(String) callback) =>
      _messaging.onTokenRefresh.listen(callback, onError: (_) {});

Enviando o Token ao Backend

Com o token em mãos, você precisa enviá-lo ao backend associado ao usuário autenticado. O momento correto para isso é logo após o login bem-sucedido e logo após o listener de renovação de token detectar um novo token. A Lambda Function que você criará para registrar o token receberá o token FCM e o usuarioId (extraído do JWT pelo Lambda Authorizer) e atualizará o registro do usuário no banco de dados.

Primeiro, atualize o schema do banco de dados para suportar múltiplos tokens FCM por usuário — pois um usuário pode ter o aplicativo instalado em mais de um dispositivo:

CREATE TABLE tokens_fcm (
    id          SERIAL PRIMARY KEY,
    usuario_id  UUID NOT NULL REFERENCES usuarios(id) ON DELETE CASCADE,
    token       TEXT NOT NULL UNIQUE,
    plataforma  VARCHAR(10) NOT NULL DEFAULT 'android'
                    CHECK (plataforma IN ('android', 'ios', 'web')),
    ativo       BOOLEAN NOT NULL DEFAULT TRUE,
    criado_em   TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    atualizado_em TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_tokens_fcm_usuario ON tokens_fcm(usuario_id) WHERE ativo = TRUE;

A Lambda Function para registrar o token:

import json
import os
import psycopg2
import psycopg2.extras

_conexao = None

def obter_conexao():
    global _conexao
    if _conexao is None or _conexao.closed:
        _conexao = psycopg2.connect(
            host=os.environ["DB_HOST"],
            dbname=os.environ["DB_NAME"],
            user=os.environ["DB_USER"],
            password=os.environ["DB_PASSWORD"],
        )
    return _conexao


def handler(event, context):
    try:
        # usuario_id extraído do JWT pelo Lambda Authorizer
        usuario_id = event["requestContext"]["authorizer"]["usuarioId"]

        body = json.loads(event.get("body") or "{}")
        token_fcm = body.get("tokenFcm")
        plataforma = body.get("plataforma", "android")

        if not token_fcm:
            return {
                "statusCode": 400,
                "headers": {"Content-Type": "application/json"},
                "body": json.dumps({"erro": "tokenFcm é obrigatório"})
            }

        conn = obter_conexao()
        with conn.cursor() as cur:
            # UPSERT: insere o token ou atualiza se já existir
            cur.execute(
                """
                INSERT INTO tokens_fcm (usuario_id, token, plataforma)
                VALUES (%s, %s, %s)
                ON CONFLICT (token) DO UPDATE
                    SET usuario_id = EXCLUDED.usuario_id,
                        plataforma = EXCLUDED.plataforma,
                        ativo = TRUE,
                        atualizado_em = NOW()
                """,
                (usuario_id, token_fcm, plataforma)
            )
        conn.commit()

        return {
            "statusCode": 200,
            "headers": {"Content-Type": "application/json"},
            "body": json.dumps({"mensagem": "token registrado com sucesso"})
        }

    except Exception as e:
        return {
            "statusCode": 500,
            "headers": {"Content-Type": "application/json"},
            "body": json.dumps({"erro": str(e)})
        }

Do lado do Flutter, o FirebaseNotificacaoServico é chamado pelo SessaoNotifier logo após a autenticação bem-sucedida:

class SessaoNotifier extends ChangeNotifier {
  // ... (continuação do SessaoNotifier do Módulo 11)

  final FirebaseNotificacaoServico _notificacaoServico;
  final HttpNotificacaoRepositorio _notificacaoRepositorio;

  /// Chamado após a autenticação bem-sucedida para registrar o token FCM.
  Future<void> _registrarTokenFcm() async {
    try {
      // Só registra se o usuário concedeu permissão
      final bool permissaoConcedida =
          await _notificacaoServico.solicitarPermissao();

      if (!permissaoConcedida) return;

      // Obtém o token atual
      final String? token = await _notificacaoServico.obterToken();
      if (token == null) return;

      // Envia ao backend
      await _notificacaoRepositorio.registrarToken(token: token);

      // Registra listener para renovação automática
      _notificacaoServico.ouvirRenovacaoToken((String novoToken) async {
        await _notificacaoRepositorio.registrarToken(token: novoToken);
      });
    } catch (e) {
      // Falha ao registrar token não é um erro fatal
      // O usuário simplesmente não receberá notificações neste dispositivo
      print('Aviso: não foi possível registrar token FCM: $e');
    }
  }
}
  Future<void> _registrarTokenFcm() async {
    if (!await _notificacaoServico.solicitarPermissao()) return;
    final token = await _notificacaoServico.obterToken();
    if (token == null) return;
    await _notificacaoRepositorio.registrarToken(token: token).catchError((_) {});
    _notificacaoServico.ouvirRenovacaoToken(
      (t) => _notificacaoRepositorio.registrarToken(token: t).catchError((_) {}),
    );
  }

Seção 5 — Recebendo Mensagens em Foreground

Quando o aplicativo está aberto e em primeiro plano, o FCM entrega as mensagens através do stream FirebaseMessaging.onMessage. Diferentemente do background e terminated, neste estado o Flutter está completamente inicializado e você tem acesso ao BuildContext, ao Provider e a qualquer outra parte da aplicação.

Um detalhe importante sobre o comportamento padrão em foreground: ao contrário do background, o FCM não exibe automaticamente uma notificação na bandeja do sistema quando o aplicativo está em foreground. A racionalidade é que, se o usuário já está usando o aplicativo, exibir uma notificação na bandeja seria perturbador e redundante. Cabe ao código do aplicativo decidir como apresentar a informação — pode ser um snackbar, um banner dentro do aplicativo, ou até mesmo ignorar a notificação visualmente e apenas atualizar o estado.

Para o aplicativo de delivery, a abordagem mais adequada é combinar uma atualização visual dentro do aplicativo (por exemplo, atualizar o status do pedido na tela que o usuário está visualizando) com, opcionalmente, uma notificação local gerada pelo código — usando o pacote flutter_local_notifications — caso o usuário não esteja na tela relacionada ao pedido.

O listener de foreground é configurado durante a inicialização do FirebaseNotificacaoServico:

import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/material.dart';

class FirebaseNotificacaoServico {
  // ... continuação

  /// Stream de notificações recebidas em foreground.
  /// Publique neste stream sempre que uma mensagem chegar.
  final _controladorMensagens =
      StreamController<RemoteMessage>.broadcast();

  Stream<RemoteMessage> get onMensagemRecebida =>
      _controladorMensagens.stream;

  /// Configura o handler de mensagens em foreground.
  /// Deve ser chamado uma única vez, tipicamente durante a inicialização do app.
  void configurarHandlerForeground() {
    FirebaseMessaging.onMessage.listen((RemoteMessage mensagem) {
      print('Mensagem recebida em foreground: ${mensagem.messageId}');
      print('Título: ${mensagem.notification?.title}');
      print('Corpo: ${mensagem.notification?.body}');
      print('Dados: ${mensagem.data}');

      // Publica no stream para que os widgets interessados possam reagir
      _controladorMensagens.add(mensagem);
    });
  }

  void dispose() {
    _controladorMensagens.close();
  }
}
import 'dart:async';
import 'package:firebase_messaging/firebase_messaging.dart';

class FirebaseNotificacaoServico {
  // ... continuação

  final _ctrl = StreamController<RemoteMessage>.broadcast();
  Stream<RemoteMessage> get onMensagemRecebida => _ctrl.stream;

  void configurarHandlerForeground() =>
      FirebaseMessaging.onMessage.listen(_ctrl.add);

  void dispose() => _ctrl.close();
}

Consumindo o Stream em um Widget

Para exibir um aviso dentro do aplicativo quando uma notificação chega em foreground, qualquer widget pode ouvir o stream onMensagemRecebida do serviço. A abordagem mais limpa é fazer isso no widget raiz da aplicação, de forma que as notificações sejam tratadas independentemente de qual tela o usuário está visualizando:

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:go_router/go_router.dart';
import '../../infraestrutura/notificacoes/firebase_notificacao_servico.dart';

class DeliveryApp extends StatefulWidget {
  const DeliveryApp({super.key});

  @override
  State<DeliveryApp> createState() => _DeliveryAppState();
}

class _DeliveryAppState extends State<DeliveryApp> {
  late final FirebaseNotificacaoServico _notificacaoServico;

  @override
  void initState() {
    super.initState();
    _notificacaoServico = context.read<FirebaseNotificacaoServico>();

    // Configura handler de foreground ao inicializar o app
    _notificacaoServico.configurarHandlerForeground();

    // Escuta mensagens em foreground para exibir feedback ao usuário
    _notificacaoServico.onMensagemRecebida.listen((mensagem) {
      // Verifica se o widget ainda está montado antes de exibir UI
      if (!mounted) return;

      final String titulo =
          mensagem.notification?.title ?? 'Nova notificação';
      final String corpo =
          mensagem.notification?.body ?? '';

      // Exibe um SnackBar com a informação da notificação
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(
          content: Column(
            mainAxisSize: MainAxisSize.min,
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Text(titulo,
                  style: const TextStyle(fontWeight: FontWeight.bold)),
              if (corpo.isNotEmpty) Text(corpo),
            ],
          ),
          duration: const Duration(seconds: 4),
          action: SnackBarAction(
            label: 'Ver',
            onPressed: () => _navegarParaDestino(mensagem.data),
          ),
        ),
      );
    });
  }

  /// Navega para a tela relevante com base nos dados da notificação.
  void _navegarParaDestino(Map<String, dynamic> dados) {
    final String? tipo = dados['tipo'] as String?;
    final String? pedidoId = dados['pedidoId'] as String?;

    if (tipo == 'status_pedido' && pedidoId != null) {
      context.push('/pedidos/$pedidoId');
    }
  }

  @override
  Widget build(BuildContext context) {
    // ... implementação do MaterialApp.router
    return const Placeholder();
  }
}
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:go_router/go_router.dart';
import '../../infraestrutura/notificacoes/firebase_notificacao_servico.dart';

class DeliveryApp extends StatefulWidget {
  const DeliveryApp({super.key});
  @override State<DeliveryApp> createState() => _DeliveryAppState();
}

class _DeliveryAppState extends State<DeliveryApp> {
  @override
  void initState() {
    super.initState();
    final svc = context.read<FirebaseNotificacaoServico>()
      ..configurarHandlerForeground();
    svc.onMensagemRecebida.listen(_exibirSnackbar);
  }

  void _exibirSnackbar(RemoteMessage m) {
    if (!mounted) return;
    ScaffoldMessenger.of(context).showSnackBar(SnackBar(
      content: Text(m.notification?.title ?? 'Nova notificação'),
      action: SnackBarAction(label: 'Ver', onPressed: () => _navegar(m.data)),
    ));
  }

  void _navegar(Map<String, dynamic> d) {
    if (d['tipo'] == 'status_pedido' && d['pedidoId'] != null) {
      context.push('/pedidos/${d['pedidoId']}');
    }
  }

  @override Widget build(BuildContext context) => const Placeholder();
}

Seção 6 — Recebendo Mensagens em Background e Terminated

O tratamento de mensagens em background e terminated é a parte mais delicada da implementação de notificações push em Flutter. As restrições são significativas e exigem um entendimento claro das limitações do ambiente de execução para evitar comportamentos inesperados.

Quando o aplicativo está em background (reduzido à bandeja, mas não encerrado) ou em estado terminated (completamente encerrado pelo usuário ou pelo sistema operacional), o FCM pode acordar o processo do aplicativo para processar uma mensagem de dados. Nesse contexto, o Flutter está em um estado de inicialização mínima — ele não está renderizando widgets, não há nenhum BuildContext disponível e os providers do GetIt não estão inicializados da mesma forma que quando o aplicativo está em foreground.

Essas restrições existem por design: o sistema operacional permite que o background handler execute com permissões limitadas e por um tempo curto, justamente para preservar a bateria e os recursos do dispositivo. Tentar acessar um BuildContext ou chamar runApp() em um background handler resultará em erro.

O que você pode fazer em um background handler:

  • Salvar dados em armazenamento local (SharedPreferences, SQLite via sqflite)
  • Fazer requisições HTTP simples
  • Agendar notificações locais com flutter_local_notifications
  • Registrar o evento em logs locais

O que você não pode fazer:

  • Acessar qualquer BuildContext
  • Usar providers do Provider ou GetIt
  • Navegar entre rotas
  • Atualizar a UI diretamente

Uma estratégia prática é usar um StreamController de broadcast com SharedPreferences como mecanismo de comunicação entre o background handler e o aplicativo quando ele volta ao foreground. O background handler salva os dados da notificação no armazenamento local, e quando o aplicativo volta ao foreground, ele lê esses dados e os processa.

O handler de background foi definido na Seção 3 (_handlerMensagemBackground). Para o delivery, você pode expandi-lo para salvar o pedidoId e o novo status na SharedPreferences, de forma que o aplicativo possa verificar se há atualizações pendentes quando for aberto:

@pragma('vm:entry-point')
Future<void> _handlerMensagemBackground(RemoteMessage mensagem) async {
  await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);

  final Map<String, dynamic> dados = mensagem.data;
  final String? tipo = dados['tipo'] as String?;

  if (tipo == 'status_pedido') {
    final String? pedidoId = dados['pedidoId'] as String?;
    final String? novoStatus = dados['status'] as String?;

    if (pedidoId != null && novoStatus != null) {
      // Salva localmente para que o app processe ao abrir
      final SharedPreferences prefs = await SharedPreferences.getInstance();
      await prefs.setString('notificacao_pendente_pedido_id', pedidoId);
      await prefs.setString('notificacao_pendente_status', novoStatus);
    }
  }
}

Quando o aplicativo retorna ao foreground, ele verifica a SharedPreferences e processa qualquer notificação pendente — por exemplo, atualizando o estado do pedido no PedidoNotifier e navegando para a tela de detalhe do pedido.


Seção 7 — Tratando o Toque em Notificações

Quando o usuário toca em uma notificação na bandeja do sistema, o aplicativo deve navegar para a tela relevante. No caso do delivery, tocar na notificação “Seu pedido saiu para entrega!” deve abrir a tela de acompanhamento do pedido correspondente. Implementar esse comportamento requer tratar dois cenários distintos: o aplicativo em background (mas não encerrado) e o aplicativo completamente encerrado.

Aplicativo em Background: onMessageOpenedApp

Quando o usuário toca em uma notificação enquanto o aplicativo está em background, o FCM dispara o evento FirebaseMessaging.onMessageOpenedApp. O aplicativo é trazido para foreground e o handler é chamado com os dados da notificação. Nesse momento, o aplicativo já está inicializado, então você pode usar o go_router para navegar:

class FirebaseNotificacaoServico {
  // ... continuação

  /// Configura o handler para quando o usuário toca em uma notificação
  /// enquanto o aplicativo está em background (não encerrado).
  void configurarHandlerToqueForeground(
    void Function(RemoteMessage mensagem) aoTocar,
  ) {
    FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage mensagem) {
      print('Usuário tocou em notificação (app estava em background)');
      print('Dados: ${mensagem.data}');
      aoTocar(mensagem);
    });
  }

  /// Verifica se o aplicativo foi aberto a partir de uma notificação
  /// quando estava completamente encerrado (terminated state).
  /// Deve ser chamado uma única vez durante a inicialização do app.
  Future<RemoteMessage?> verificarMensagemInicial() async {
    final RemoteMessage? mensagem =
        await FirebaseMessaging.instance.getInitialMessage();

    if (mensagem != null) {
      print('App aberto a partir de notificação (estava encerrado)');
      print('Dados: ${mensagem.data}');
    }

    return mensagem;
  }
}
  void configurarHandlerToqueForeground(void Function(RemoteMessage) cb) =>
      FirebaseMessaging.onMessageOpenedApp.listen(cb);

  Future<RemoteMessage?> verificarMensagemInicial() =>
      FirebaseMessaging.instance.getInitialMessage();

Aplicativo Encerrado: getInitialMessage

Quando o aplicativo está completamente encerrado e o usuário toca em uma notificação, o sistema operacional inicia o aplicativo. Nesse caso, FirebaseMessaging.onMessageOpenedApp não é disparado — o evento já aconteceu antes do listener ser registrado. Para recuperar a mensagem que iniciou o aplicativo, você usa getInitialMessage(), que deve ser chamado uma única vez durante a inicialização.

O lugar correto para chamar getInitialMessage() é no initState() do widget raiz, após a inicialização do Firebase. Se houver uma mensagem inicial, você agenda uma navegação para após o primeiro frame ser renderizado, usando WidgetsBinding.instance.addPostFrameCallback:

class _DeliveryAppState extends State<DeliveryApp> {
  @override
  void initState() {
    super.initState();
    _inicializarNotificacoes();
  }

  Future<void> _inicializarNotificacoes() async {
    final FirebaseNotificacaoServico svc =
        context.read<FirebaseNotificacaoServico>();

    // Configura handlers
    svc.configurarHandlerForeground();
    svc.configurarHandlerToqueForeground(_aoTocarNotificacao);

    // Verifica se o app foi aberto por um toque em notificação
    final RemoteMessage? mensagemInicial =
        await svc.verificarMensagemInicial();

    if (mensagemInicial != null && mounted) {
      // Aguarda o primeiro frame para ter acesso ao roteador
      WidgetsBinding.instance.addPostFrameCallback((_) {
        if (mounted) _aoTocarNotificacao(mensagemInicial);
      });
    }
  }

  void _aoTocarNotificacao(RemoteMessage mensagem) {
    final String? tipo = mensagem.data['tipo'] as String?;
    final String? pedidoId = mensagem.data['pedidoId'] as String?;

    if (tipo == 'status_pedido' && pedidoId != null) {
      // O roteador do go_router está disponível no contexto
      GoRouter.of(context).push('/pedidos/$pedidoId');
    }
  }
}
  Future<void> _inicializarNotificacoes() async {
    final svc = context.read<FirebaseNotificacaoServico>()
      ..configurarHandlerForeground()
      ..configurarHandlerToqueForeground(_aoTocar);
    final msg = await svc.verificarMensagemInicial();
    if (msg != null && mounted) {
      WidgetsBinding.instance.addPostFrameCallback(
        (_) { if (mounted) _aoTocar(msg); },
      );
    }
  }

  void _aoTocar(RemoteMessage m) {
    final id = m.data['pedidoId'] as String?;
    if (m.data['tipo'] == 'status_pedido' && id != null) {
      GoRouter.of(context).push('/pedidos/$id');
    }
  }

Seção 8 — A Estrutura Completa de uma Mensagem FCM

Entender a estrutura completa de uma mensagem FCM é fundamental tanto para o lado do Flutter (que recebe e interpreta a mensagem) quanto para o lado do backend (que a constrói e a envia). Uma mensagem mal estruturada pode resultar em notificações que aparecem no emulador mas não no dispositivo físico, ou dados que chegam como null quando deveriam ter um valor.

A estrutura de uma mensagem FCM enviada pelo backend (no formato JSON para a FCM REST API v1) tem os seguintes campos principais:

{
  "message": {
    "token": "token_fcm_do_dispositivo_aqui",
    "notification": {
      "title": "Pedido #42 atualizado!",
      "body": "Seu pedido saiu para entrega. Tempo estimado: 20 minutos."
    },
    "data": {
      "tipo": "status_pedido",
      "pedidoId": "42",
      "status": "saiu_para_entrega",
      "timestamp": "2025-11-15T14:30:00Z"
    },
    "android": {
      "priority": "HIGH",
      "notification": {
        "channel_id": "pedidos_delivery",
        "click_action": "FLUTTER_NOTIFICATION_CLICK"
      }
    }
  }
}

O Campo notification

O campo notification contém o título e o corpo que o sistema operacional usará para construir e exibir a notificação na bandeja. Quando esse campo está presente e o aplicativo está em background ou terminated, o sistema operacional exibe a notificação automaticamente, sem precisar acordar o processo do aplicativo. Isso é eficiente em termos de bateria, mas tem uma limitação: a aparência da notificação é controlada pelo sistema, não pelo código do aplicativo.

No aplicativo de delivery em foreground, o campo notification é ignorado pelo Firebase — o FCM não exibe a notificação automaticamente nesse estado, como você aprendeu na Seção 5. É por isso que o código de foreground precisa construir e exibir a notificação manualmente (via SnackBar ou flutter_local_notifications).

O Campo data

O campo data é um mapa de string para string — observe que todos os valores devem ser strings, não números ou booleanos. Se você precisar enviar um número como o pedidoId, ele deve ser serializado como string no backend e convertido de volta para número no Flutter quando necessário.

No Flutter, o conteúdo do campo data é acessível através de mensagem.data como um Map<String, dynamic>. Os dados chegam intactos tanto em foreground quanto em background, o que os torna o veículo ideal para informações estruturadas que o aplicativo precisa processar — como o ID do pedido para navegar para a tela correta ou o novo status para atualizar o estado local.

Android: Canais de Notificação e Prioridade

A partir do Android 8.0 (API level 26), o sistema de notificações é organizado em canais. Cada canal tem configurações próprias de som, vibração, importância e comportamento de LED. O usuário pode desabilitar individualmente cada canal — por exemplo, um usuário pode habilitar as notificações de atualização de status de pedido, mas desabilitar as notificações de promoções.

Para o aplicativo de delivery, crie pelo menos dois canais:

  • pedidos_delivery — alta importância, para atualizações de status de pedido
  • promocoes_delivery — importância padrão, para ofertas e promoções

A criação de canais no Flutter é feita programaticamente usando o pacote flutter_local_notifications, que você pode usar em conjunto com o firebase_messaging para ter controle total sobre a aparência e o comportamento das notificações.

priority no Android

O campo priority em android.priority pode ser NORMAL ou HIGH. Com prioridade NORMAL, o FCM pode agrupar e entregar mensagens em lote para economizar bateria — a mensagem pode demorar alguns minutos para chegar. Com prioridade HIGH, o FCM entrega a mensagem imediatamente, acordando o dispositivo se necessário. Para notificações de status de pedido do delivery, use sempre HIGH — o usuário quer saber imediatamente que o entregador saiu. Para boletins de promoções, NORMAL é adequado.

TTL: Tempo de Vida da Mensagem

O campo ttl (Time To Live) define por quanto tempo o FCM deve tentar entregar a mensagem ao dispositivo. Se o dispositivo estiver offline e o TTL expirar antes de ele reconectar, a mensagem é descartada. O valor padrão é 4 semanas — muito longo para notificações de status de pedido. Para uma notificação “Seu pedido saiu para entrega”, um TTL de 1 hora faz mais sentido: se o dispositivo estiver offline por mais de uma hora, a notificação já perdeu a relevância.


Seção 9 — Enviando Notificações da Lambda AWS

Com o Flutter configurado para receber notificações e o backend para armazenar tokens FCM, o próximo passo é implementar o envio de notificações a partir das Lambda Functions quando o status de um pedido é atualizado. O Firebase Admin SDK para Python é a biblioteca que você usará para isso — ela fornece uma interface de alto nível para a FCM API v1, cuidando da autenticação OAuth 2.0 com a Google API automaticamente.

Configurando o Firebase Admin SDK na Lambda

O Firebase Admin SDK é instalado via pip e precisa ser adicionado como um Lambda Layer ou empacotado junto com o código da função. Para criar o layer:

pip install firebase-admin -t python/
zip -r firebase-admin-layer.zip python/
# faça upload do arquivo ZIP no console AWS Lambda em "Layers"

Para autenticar o Admin SDK com o Firebase, você precisa de uma Service Account — uma conta de serviço do Google com permissões para enviar mensagens FCM. No console Firebase, vá em Configurações do Projeto > Contas de serviço > Gerar nova chave privada. Faça o download do arquivo JSON resultante e armazene seu conteúdo como um segredo no AWS Secrets Manager, na Lambda Function delivery-processar-pedido.

import json
import os
import boto3
import firebase_admin
from firebase_admin import credentials, messaging

_firebase_inicializado = False

def inicializar_firebase():
    global _firebase_inicializado
    if _firebase_inicializado:
        return

    # Lê as credenciais do Secrets Manager
    cliente_secrets = boto3.client('secretsmanager')
    resposta = cliente_secrets.get_secret_value(
        SecretId=os.environ['FIREBASE_SERVICE_ACCOUNT_SECRET_ID']
    )
    credenciais_dict = json.loads(resposta['SecretString'])

    cred = credentials.Certificate(credenciais_dict)
    firebase_admin.initialize_app(cred)
    _firebase_inicializado = True


def enviar_notificacao_status_pedido(
    tokens_fcm: list[str],
    pedido_id: int,
    novo_status: str,
    nome_usuario: str
) -> dict:
    """
    Envia uma notificação de atualização de status para uma lista de tokens FCM.
    Retorna um relatório de entregas bem-sucedidas e falhas.
    """
    # Mensagens de status amigáveis para o usuário
    mensagens_status = {
        'confirmado':          ('Pedido confirmado!',   'Seu pedido foi confirmado e será preparado em breve.'),
        'em_preparo':          ('Na cozinha!',          'Seu pedido está sendo preparado com carinho.'),
        'saiu_para_entrega':   ('A caminho!',           'Seu pedido saiu para entrega. Aguarde!'),
        'entregue':            ('Entregue!',            'Seu pedido chegou. Bom apetite!'),
        'cancelado':           ('Pedido cancelado',     'Seu pedido foi cancelado. Entre em contato se precisar de ajuda.'),
    }

    titulo, corpo = mensagens_status.get(
        novo_status,
        ('Pedido atualizado', f'Status: {novo_status}')
    )

    mensagem_multicast = messaging.MulticastMessage(
        tokens=tokens_fcm,
        notification=messaging.Notification(title=titulo, body=corpo),
        data={
            'tipo': 'status_pedido',
            'pedidoId': str(pedido_id),
            'status': novo_status,
        },
        android=messaging.AndroidConfig(
            priority='high',
            ttl=3600,  # 1 hora em segundos
            notification=messaging.AndroidNotification(
                channel_id='pedidos_delivery',
                click_action='FLUTTER_NOTIFICATION_CLICK',
            ),
        ),
    )

    resposta = messaging.send_each_for_multicast(mensagem_multicast)

    print(f"Notificações enviadas: {resposta.success_count} sucesso, "
          f"{resposta.failure_count} falha")

    # Coleta os tokens com falha para marcar como inativos no banco
    tokens_invalidos = []
    for i, resultado in enumerate(resposta.responses):
        if not resultado.success:
            codigo_erro = resultado.exception.code if resultado.exception else 'UNKNOWN'
            print(f"Falha no token {i}: {codigo_erro}")
            # Token não registrado indica que o app foi desinstalado
            if codigo_erro in ('registration-token-not-registered', 'invalid-registration-token'):
                tokens_invalidos.append(tokens_fcm[i])

    return {
        'sucesso': resposta.success_count,
        'falha': resposta.failure_count,
        'tokens_invalidos': tokens_invalidos
    }

Integrando o Envio de Notificações ao Processador de Pedidos

A Lambda delivery-processar-pedido — que você criou no Módulo 10 para processar mensagens da fila SQS quando um pedido é criado ou atualizado — é o lugar correto para adicionar o envio de notificações. Quando o status de um pedido muda, você busca os tokens FCM do usuário no banco de dados e envia a notificação:

import json
import os
import psycopg2
import psycopg2.extras

# importações do Firebase (após inicializar)

_conexao = None

def obter_conexao():
    global _conexao
    if _conexao is None or _conexao.closed:
        _conexao = psycopg2.connect(
            host=os.environ["DB_HOST"],
            dbname=os.environ["DB_NAME"],
            user=os.environ["DB_USER"],
            password=os.environ["DB_PASSWORD"],
        )
    return _conexao


def handler(event, context):
    inicializar_firebase()

    for registro in event.get("Records", []):
        try:
            dados = json.loads(registro["body"])
            pedido_id = dados["pedidoId"]
            novo_status = dados.get("novoStatus", "confirmado")

            conn = obter_conexao()
            with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
                # Atualiza o status do pedido
                cur.execute(
                    """
                    UPDATE pedidos
                       SET status = %s, atualizado_em = NOW()
                     WHERE id = %s
                    RETURNING usuario_id
                    """,
                    (novo_status, pedido_id)
                )
                pedido = cur.fetchone()
                if not pedido:
                    print(f"Pedido {pedido_id} não encontrado")
                    continue

                usuario_id = pedido['usuario_id']

                # Busca os tokens FCM ativos do usuário
                cur.execute(
                    "SELECT token FROM tokens_fcm WHERE usuario_id = %s AND ativo = TRUE",
                    (str(usuario_id),)
                )
                tokens = [row['token'] for row in cur.fetchall()]

            conn.commit()

            if tokens:
                resultado = enviar_notificacao_status_pedido(
                    tokens_fcm=tokens,
                    pedido_id=pedido_id,
                    novo_status=novo_status,
                    nome_usuario=''
                )

                # Marca tokens inválidos como inativos para não tentar novamente
                if resultado['tokens_invalidos']:
                    with conn.cursor() as cur:
                        cur.execute(
                            "UPDATE tokens_fcm SET ativo = FALSE WHERE token = ANY(%s)",
                            (resultado['tokens_invalidos'],)
                        )
                    conn.commit()
            else:
                print(f"Nenhum token FCM ativo para usuário {usuario_id}")

        except Exception as e:
            print(f"Erro ao processar mensagem: {e}")
            raise  # retorna à fila SQS para nova tentativa

Seção 10 — Notificações Locais com flutter_local_notifications

O pacote flutter_local_notifications é usado em conjunto com o firebase_messaging para duas finalidades principais: exibir notificações quando o aplicativo está em foreground (já que o FCM não faz isso automaticamente) e criar canais de notificação Android com configurações personalizadas de som, vibração e importância.

Para adicionar o pacote:

dependencies:
  flutter_local_notifications: ^18.0.0

A inicialização do flutter_local_notifications deve acontecer no main.dart, após a inicialização do Firebase, pois os canais precisam existir antes de qualquer notificação ser exibida. Para o delivery, você vai criar dois canais — um para atualizações de pedido e outro para promoções:

import 'package:flutter_local_notifications/flutter_local_notifications.dart';

final FlutterLocalNotificationsPlugin flutterLocalNotifications =
    FlutterLocalNotificationsPlugin();

Future<void> inicializarNotificacoesLocais() async {
  // Configurações de inicialização para Android
  const AndroidInitializationSettings androidSettings =
      AndroidInitializationSettings('@mipmap/ic_launcher');

  const InitializationSettings settings = InitializationSettings(
    android: androidSettings,
  );

  await flutterLocalNotifications.initialize(
    settings,
    onDidReceiveNotificationResponse: (NotificationResponse resposta) {
      // Chamado quando o usuário toca em uma notificação local
      // resposta.payload contém os dados que você definiu ao exibir a notificação
      print('Notificação local tocada: ${resposta.payload}');
    },
  );

  // Cria o canal de notificações de pedidos (alta importância)
  const AndroidNotificationChannel canalPedidos = AndroidNotificationChannel(
    'pedidos_delivery',
    'Atualizações de Pedidos',
    description: 'Notificações sobre o status dos seus pedidos de delivery',
    importance: Importance.high,
    playSound: true,
    enableVibration: true,
  );

  // Cria o canal de promoções (importância padrão)
  const AndroidNotificationChannel canalPromocoes = AndroidNotificationChannel(
    'promocoes_delivery',
    'Promoções e Ofertas',
    description: 'Ofertas especiais e promoções do delivery',
    importance: Importance.defaultImportance,
    playSound: false,
  );

  final AndroidFlutterLocalNotificationsPlugin? androidPlugin =
      flutterLocalNotifications.resolvePlatformSpecificImplementation<
          AndroidFlutterLocalNotificationsPlugin>();

  await androidPlugin?.createNotificationChannel(canalPedidos);
  await androidPlugin?.createNotificationChannel(canalPromocoes);
}

/// Exibe uma notificação local a partir de uma mensagem FCM recebida em foreground.
Future<void> exibirNotificacaoLocal(RemoteMessage mensagem) async {
  final RemoteNotification? notificacao = mensagem.notification;
  if (notificacao == null) return;

  const AndroidNotificationDetails androidDetails = AndroidNotificationDetails(
    'pedidos_delivery',
    'Atualizações de Pedidos',
    importance: Importance.high,
    priority: Priority.high,
  );

  await flutterLocalNotifications.show(
    notificacao.hashCode,
    notificacao.title,
    notificacao.body,
    const NotificationDetails(android: androidDetails),
    payload: jsonEncode(mensagem.data),  // dados acessíveis no onDidReceiveNotificationResponse
  );
}
import 'dart:convert';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';

final _localNotif = FlutterLocalNotificationsPlugin();

Future<void> inicializarNotificacoesLocais() async {
  await _localNotif.initialize(
    const InitializationSettings(
      android: AndroidInitializationSettings('@mipmap/ic_launcher'),
    ),
  );
  final android = _localNotif.resolvePlatformSpecificImplementation<
      AndroidFlutterLocalNotificationsPlugin>();
  await android?.createNotificationChannel(const AndroidNotificationChannel(
    'pedidos_delivery', 'Atualizações de Pedidos', importance: Importance.high,
  ));
  await android?.createNotificationChannel(const AndroidNotificationChannel(
    'promocoes_delivery', 'Promoções', importance: Importance.defaultImportance,
  ));
}

Future<void> exibirNotificacaoLocal(RemoteMessage m) async {
  final n = m.notification;
  if (n == null) return;
  await _localNotif.show(
    n.hashCode, n.title, n.body,
    const NotificationDetails(android: AndroidNotificationDetails(
      'pedidos_delivery', 'Atualizações de Pedidos',
      importance: Importance.high, priority: Priority.high,
    )),
    payload: jsonEncode(m.data),
  );
}

Seção 11 — Privacidade e Preferências do Usuário

Notificações são um recurso poderoso, mas também um recurso que pode facilmente se tornar uma fonte de irritação para o usuário se mal utilizado. Respeitar as preferências do usuário não é apenas uma boa prática de experiência do usuário — em muitas jurisdições, incluindo os países da União Europeia sob o GDPR, é uma obrigação legal.

Solicitando Permissão no Momento Certo

A regra de ouro é nunca solicitar a permissão de notificações logo após a abertura ou o login no aplicativo. O usuário ainda não entendeu o valor que as notificações trazem e tende a negar reflexivamente. O momento ideal é contextualizado: após o usuário confirmar o primeiro pedido, apresente uma explicação clara de por que as notificações são úteis neste contexto específico.

Uma boa abordagem é exibir um diálogo personalizado antes de solicitar a permissão do sistema operacional, com a explicação do valor:

Future<void> solicitarPermissaoContextualizada(BuildContext context) async {
  // Primeiro exibe o diálogo explicativo personalizado
  final bool? usuarioAceitou = await showDialog<bool>(
    context: context,
    builder: (context) => AlertDialog(
      title: const Text('Ativar notificações?'),
      content: const Text(
        'Ative as notificações para receber atualizações em tempo real '
        'sobre o status do seu pedido: confirmação, preparo e entrega. '
        'Você pode desativar a qualquer momento nas configurações do aplicativo.',
      ),
      actions: [
        TextButton(
          onPressed: () => Navigator.of(context).pop(false),
          child: const Text('Agora não'),
        ),
        FilledButton(
          onPressed: () => Navigator.of(context).pop(true),
          child: const Text('Ativar'),
        ),
      ],
    ),
  );

  if (usuarioAceitou == true) {
    // Só agora solicita a permissão do sistema operacional
    await FirebaseMessaging.instance.requestPermission(
      alert: true,
      badge: true,
      sound: true,
    );
  }
}
Future<void> solicitarPermissaoContextualizada(BuildContext context) async {
  final aceito = await showDialog<bool>(
    context: context,
    builder: (_) => AlertDialog(
      title: const Text('Ativar notificações?'),
      content: const Text(
        'Receba atualizações em tempo real sobre o status do seu pedido. '
        'Você pode desativar nas configurações a qualquer momento.',
      ),
      actions: [
        TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Agora não')),
        FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('Ativar')),
      ],
    ),
  );
  if (aceito == true) {
    await FirebaseMessaging.instance.requestPermission(alert: true, badge: true, sound: true);
  }
}

Gerenciando Preferências por Tipo de Notificação

Uma prática que melhora significativamente a retenção de usuários é permitir que eles controlem granularmente quais tipos de notificações desejam receber, em vez de uma opção binária de ativar/desativar tudo. Para o delivery, as preferências relevantes poderiam ser:

  • Atualizações de status de pedido (recomendado manter sempre ativo)
  • Promoções e ofertas especiais
  • Novos produtos no cardápio

Essas preferências são armazenadas no próprio dispositivo (SharedPreferences) e também propagadas ao backend, que usa a tabela de tópicos FCM ou verifica as preferências antes de enviar cada notificação. O FCM suporta também o conceito de tópicos — em vez de enviar uma mensagem a um token específico, você publica em um tópico (promocoes, por exemplo), e todos os dispositivos inscritos naquele tópico recebem a mensagem. Isso simplifica o envio de notificações em massa, como promoções — em vez de buscar todos os tokens ativos no banco de dados, você simplesmente publica no tópico.

Lidando Graciosamente com a Negação de Permissão

Se o usuário negar a permissão de notificações, o aplicativo deve continuar funcionando normalmente. Notificações são uma funcionalidade de conveniência, não um requisito para o funcionamento do delivery. O código não deve bloquear a navegação ou exibir erros em resposta à negação.

É válido, no entanto, informar ao usuário em um momento posterior que ele pode ativar as notificações nas configurações do sistema, especialmente se ele perguntar como acompanhar o status do pedido. Uma frase discreta como “Ative as notificações nas configurações para receber atualizações automáticas do status do pedido” é informativa sem ser insistente.


Seção 12 — Depurando Problemas Comuns em Notificações Push

Notificações push são notoriamente difíceis de depurar porque o caminho que uma mensagem percorre envolve múltiplos sistemas: o seu backend, a API do Firebase, o Google Play Services no dispositivo Android e, finalmente, o código Flutter. Um problema em qualquer um desses pontos pode resultar em uma notificação que simplesmente não chega, sem nenhuma mensagem de erro óbvia.

Notificação Funciona no Emulador mas não no Dispositivo Físico

Este é um dos problemas mais comuns. O emulador Android com Google Play Services instalado funciona de forma muito semelhante a um dispositivo físico para fins de FCM. No entanto, alguns dispositivos físicos — principalmente os de fabricantes chineses como Xiaomi, Huawei e OPPO — implementam agressivas otimizações de bateria que matam processos em background, impedindo que as mensagens FCM sejam recebidas quando o aplicativo não está em foreground.

A solução nesses dispositivos é instruir o usuário a desabilitar a otimização de bateria para o aplicativo nas configurações do sistema. Cada fabricante implementa isso de forma diferente — há recursos online que listam o caminho específico para os modelos mais populares. No código, você pode verificar se o dispositivo está com a otimização de bateria ativada e, se estiver, exibir uma mensagem orientando o usuário a desabilitá-la.

Token FCM Vazio ou Nulo

Se FirebaseMessaging.instance.getToken() retorna null, as causas mais comuns são: o google-services.json está em uma pasta incorreta (deve estar em android/app/, não em android/); o plugin do Google Services não está configurado corretamente no build.gradle; ou o emulador não tem o Google Play Services instalado (use um emulador com a imagem “Google APIs” ou “Google Play”).

Mensagem Chega mas Notificação não Aparece

Se o onMessage stream está recebendo mensagens, mas nenhuma notificação aparece na bandeja, verifique se o canal de notificação está criado corretamente. A partir do Android 8.0, uma notificação sem um canal válido é silenciosamente ignorada pelo sistema. Use o channel_id no campo android.notification da mensagem FCM e verifique se esse canal foi criado pelo flutter_local_notifications na inicialização do aplicativo.


Seção 13 — Tópicos FCM: Notificações em Massa e Segmentadas

Além do envio de notificações para tokens individuais, o FCM suporta o conceito de tópicos. Um tópico é um canal de publicação-assinatura: o dispositivo se inscreve em um tópico pelo nome, e o backend publica mensagens nesse tópico. Todos os dispositivos inscritos recebem a mensagem, sem que o backend precise conhecer ou gerenciar os tokens individualmente. Para o aplicativo de delivery, os tópicos são especialmente úteis para comunicações que se aplicam a segmentos de usuários, como promoções do dia ou avisos de manutenção da plataforma.

Inscrevendo o Dispositivo em Tópicos

A inscrição em tópicos é feita pelo próprio dispositivo, chamando o método subscribeToTopic() do FirebaseMessaging. Isso é simples e não requer nenhuma chamada ao backend:

class FirebaseNotificacaoServico {
  // ... continuação

  /// Inscreve o dispositivo no tópico de promoções gerais do delivery.
  Future<void> inscreverEmTopico(String nomeTopic) async {
    try {
      await _messaging.subscribeToTopic(nomeTopic);
      print('Inscrito no tópico: $nomeTopic');
    } catch (e) {
      print('Erro ao se inscrever no tópico $nomeTopic: $e');
    }
  }

  /// Remove a inscrição do dispositivo de um tópico.
  Future<void> cancelarInscricaoEmTopico(String nomeTopic) async {
    try {
      await _messaging.unsubscribeFromTopic(nomeTopic);
      print('Desinscrito do tópico: $nomeTopic');
    } catch (e) {
      print('Erro ao cancelar inscrição no tópico $nomeTopic: $e');
    }
  }
}
  Future<void> inscreverEmTopico(String t) =>
      _messaging.subscribeToTopic(t).catchError((_) {});

  Future<void> cancelarInscricaoEmTopico(String t) =>
      _messaging.unsubscribeFromTopic(t).catchError((_) {});

Após o login bem-sucedido, você pode inscrever automaticamente o usuário nos tópicos relevantes. Por exemplo, todos os usuários autenticados são inscritos no tópico avisos_plataforma, que será usado para comunicar manutenções programadas ou novidades do serviço. A inscrição em tópicos de promoções pode ser opcional, condicionada às preferências do usuário.

Enviando para um Tópico a partir da Lambda

Enviar uma notificação para um tópico a partir da Lambda é tão simples quanto enviar para um token individual — a diferença é que em vez de especificar o token, você especifica o topic:

import firebase_admin
from firebase_admin import messaging


def enviar_para_topico(topico: str, titulo: str, corpo: str, dados: dict) -> str:
    """
    Envia uma notificação para todos os dispositivos inscritos em um tópico.
    Retorna o message_id da mensagem enviada.
    """
    mensagem = messaging.Message(
        topic=topico,
        notification=messaging.Notification(title=titulo, body=corpo),
        data={k: str(v) for k, v in dados.items()},  # todos os valores devem ser strings
        android=messaging.AndroidConfig(
            priority='normal',  # promoções não precisam de alta prioridade
            ttl=86400,  # 24 horas
        ),
    )
    message_id = messaging.send(mensagem)
    print(f"Mensagem enviada para tópico '{topico}': {message_id}")
    return message_id


# Exemplo: enviar promoção do dia para todos os usuários
def handler_enviar_promocao(event, context):
    inicializar_firebase()
    body = json.loads(event.get('body') or '{}')

    message_id = enviar_para_topico(
        topico='promocoes_delivery',
        titulo=body.get('titulo', 'Oferta especial!'),
        corpo=body.get('corpo', 'Confira as promoções de hoje no delivery.'),
        dados={
            'tipo': 'promocao',
            'promocaoId': str(body.get('promocaoId', '')),
        }
    )

    return {
        'statusCode': 200,
        'headers': {'Content-Type': 'application/json'},
        'body': json.dumps({'messageId': message_id})
    }

Tópicos Condicionais: Combinando Segmentos

O FCM suporta expressões condicionais de tópicos, que permitem enviar uma mensagem apenas para dispositivos inscritos em determinadas combinações de tópicos. Uma expressão condicional é uma string que combina nomes de tópicos com os operadores && (E lógico), || (OU lógico) e ! (negação). Por exemplo, a condição 'promocoes_delivery' in topics && 'sp_delivery' in topics envia a mensagem apenas para usuários inscritos tanto em promocoes_delivery quanto em sp_delivery — ou seja, usuários de São Paulo que aceitaram receber promoções.

Para o projeto de delivery no escopo desta disciplina, o envio por token (para notificações de status de pedido) e por tópico (para promoções gerais) é suficiente. A expressão condicional é um recurso avançado para produtos com bases de usuários maiores e segmentações mais sofisticadas.


Seção 14 — Monitoramento e Analytics de Notificações

Enviar notificações é apenas metade do trabalho. Para avaliar se as notificações estão sendo entregues corretamente e gerando o engajamento esperado, você precisa monitorar métricas de desempenho. O Firebase oferece ferramentas de analytics integradas que rastreiam automaticamente as taxas de entrega e abertura de notificações, sem necessidade de código adicional no aplicativo.

Firebase Cloud Messaging Analytics

No console Firebase, a seção “Cloud Messaging” exibe métricas automaticamente coletadas para cada campanha de notificação enviada. As métricas mais importantes para o delivery são a taxa de entrega (delivery rate), que indica a porcentagem de mensagens que foram efetivamente entregues pelo FCM ao dispositivo; a taxa de abertura (open rate), que indica a porcentagem de usuários que tocaram na notificação após recebê-la; e a taxa de tokens inválidos, que indica quantos tokens da sua base de dados já não são mais válidos (de aplicativos desinstalados).

Uma taxa de entrega abaixo de 95% pode indicar problemas com tokens desatualizados no banco de dados ou com a conectividade dos dispositivos dos usuários. Uma taxa de abertura abaixo de 20% para notificações de status de pedido — que são altamente relevantes — pode indicar que as mensagens estão sendo exibidas de forma pouco atraente ou nos horários errados.

CloudWatch como Complemento ao Firebase Analytics

As Lambda Functions que enviam notificações geram logs automáticos no CloudWatch, como você aprendeu no Módulo 10. Você pode usar o CloudWatch Logs Insights para consultar esses logs e extrair métricas detalhadas que o Firebase Analytics não oferece nativamente, como o tempo médio entre a atualização de status de um pedido e o envio da notificação correspondente, ou a distribuição de falhas de entrega por código de erro do FCM.

Uma query útil para identificar tokens inválidos que precisam ser removidos do banco de dados seria filtrar os logs da Lambda delivery-processar-pedido buscando pelas mensagens de erro relacionadas a tokens registration-token-not-registered. Se você estiver usando structured logging (como aprendeu no Módulo 10), a query pode ser muito precisa:

fields @timestamp, @message
| filter @logGroup = '/aws/lambda/delivery-prod-processar-pedido'
| filter @message like /token_invalido/
| stats count() as qtd_tokens_invalidos by bin(1h)
| sort @timestamp desc

Uma alta frequência de tokens inválidos é um sinal de que os usuários estão desinstalando o aplicativo — uma métrica de engajamento indireta que merece atenção.


Seção 15 — Fluxo Completo de Integração: Do Pedido à Notificação

Para consolidar todos os conceitos deste módulo e dos módulos anteriores, vale a pena traçar o fluxo completo de uma notificação de status de pedido do delivery, da ação do usuário até a notificação aparecer na tela do smartphone. Esse fluxo integra componentes de todos os módulos desde o Módulo 09.

sequenceDiagram
    actor U as Usuário (Flutter)
    participant AG as API Gateway
    participant LC as Lambda criar-pedido
    participant DB as RDS PostgreSQL
    participant SQ as SQS FIFO
    participant LP as Lambda processar-pedido
    participant FCM as Firebase FCM
    participant OS as Sistema Operacional Android

    U->>AG: POST /pedidos (+ JWT)
    AG->>LC: invoca (JWT validado pelo Authorizer)
    LC->>DB: INSERT pedidos + itens_pedido
    LC->>SQ: envia mensagem {pedidoId, novoStatus: 'confirmado'}
    LC-->>U: 201 {pedidoId: 42}
    Note over SQ,LP: Processamento assíncrono
    SQ->>LP: dispara com a mensagem
    LP->>DB: UPDATE pedidos SET status='confirmado'
    LP->>DB: SELECT token FROM tokens_fcm WHERE usuario_id = ...
    DB-->>LP: ["token_fcm_abc123"]
    LP->>FCM: send_each_for_multicast(tokens, notification, data)
    FCM->>OS: entrega mensagem
    OS->>U: exibe notificação "Pedido confirmado!"

O diagrama acima mostra a integração completa entre os módulos 09, 10, 11 e 12. O API Gateway do Módulo 10 valida o JWT do Módulo 11 antes de invocar a Lambda. A Lambda cria o pedido e enfileira a mensagem no SQS do Módulo 10. O processador de pedidos busca os tokens FCM armazenados no banco (Módulo 12, Seção 4) e envia a notificação via Firebase Admin SDK. O FCM entrega a mensagem ao sistema operacional, que a exibe ao usuário.

O Papel do Estado do Aplicativo na Recepção

Dependendo do estado do aplicativo no momento em que a notificação é entregue, o tratamento será diferente. Se o usuário estiver na tela de acompanhamento do pedido (foreground), o onMessage stream receberá a mensagem e o PedidoNotifier atualizará o status em tela em tempo real — o usuário verá o status mudar sem precisar recarregar. Se o aplicativo estiver em background, a notificação aparece na bandeja e o background handler salva o novo status na SharedPreferences. Se o aplicativo estiver encerrado, o sistema operacional exibe a notificação, e quando o usuário toca nela, o getInitialMessage() recupera os dados e navega para a tela do pedido correspondente.

Essa cobertura dos três estados garante que o usuário sempre seja notificado sobre as atualizações do pedido, independentemente de como ele esteja usando o dispositivo naquele momento — que é exatamente a experiência que diferencia um aplicativo de delivery profissional de um protótipo acadêmico.

Garantias de Entrega e Cenários de Falha

O FCM oferece a garantia de entrega “best effort” para mensagens de prioridade normal, e entrega quase garantida para mensagens de alta prioridade — mas não há garantia absoluta de que 100% das mensagens chegarão. Dispositivos sem conexão com a internet no momento do envio receberão a mensagem quando reconectarem, desde que o TTL não tenha expirado. Dispositivos com Google Play Services corrompidos ou desatualizados podem não receber mensagens.

Para o aplicativo de delivery, isso significa que a interface não pode depender exclusivamente de notificações push para manter o estado atualizado. A tela de acompanhamento do pedido deve implementar um mecanismo de polling periódico — chamando GET /pedidos/{pedidoId} a cada 30 ou 60 segundos quando o pedido está em andamento — para garantir que o status seja atualizado mesmo se a notificação não chegar. As notificações são o mecanismo primário de comunicação proativa, mas o polling é a rede de segurança que garante a consistência da informação.


Seção 16 — FCM no Contexto da Arquitetura Hexagonal do Projeto

Ao longo desta disciplina, você vem construindo o projeto de delivery seguindo a arquitetura hexagonal com Domain-Driven Design, introduzida no Módulo 07. Com a adição das notificações push, é importante refletir sobre como o firebase_messaging e o Firebase Admin SDK se encaixam nessa arquitetura — e garantir que eles não violem as regras das camadas que você estabeleceu.

Mantendo a Camada de Domínio Limpa

A regra fundamental da arquitetura hexagonal é que a camada de domínio não pode ter dependências em frameworks ou serviços externos. Isso significa que nenhuma classe da camada de domínio pode importar package:firebase_messaging/firebase_messaging.dart. A camada de domínio pode definir uma interface abstrata INotificacaoRepositorio que especifica o contrato que o sistema de notificações deve cumprir — por exemplo, um método registrarToken(String token) e um método ouvirStatusPedido(Stream<StatusPedido> stream) — mas a implementação concreta usando Firebase fica na camada de infraestrutura.

Essa separação tem uma consequência prática muito valiosa: se você decidir, no futuro, substituir o FCM por outra plataforma de notificações — como o OneSignal ou o AWS SNS — você precisará apenas criar uma nova implementação da interface INotificacaoRepositorio na camada de infraestrutura e atualizar a injeção de dependências no GetIt. A camada de domínio, os providers e os widgets permanecem intocados.

A classe FirebaseNotificacaoServico que você construiu ao longo deste módulo é uma implementação da interface de domínio. Ela vive na camada de infraestrutura, junto com o HttpProdutoRepositorio e o AutenticacaoServico dos módulos anteriores. O SessaoNotifier recebe uma instância de INotificacaoRepositorio (não de FirebaseNotificacaoServico diretamente) por meio da injeção de dependências do GetIt.

Organização de Pastas para as Notificações

Seguindo a estrutura Feature-First que guia a organização do projeto desde o Módulo 07, as notificações formam uma feature própria, com suas três camadas:

A pasta features/notificacoes/dominio/ contém a interface INotificacaoRepositorio e os modelos de domínio relacionados, como NotificacaoDelivery — uma entidade que representa uma notificação específica do contexto do delivery, com campos tipados como tipo (um enum: StatusPedido, Promocao, AvisoPlataforma) e pedidoId (opcional, presente apenas para notificações de status de pedido).

A pasta features/notificacoes/infraestrutura/ contém o FirebaseNotificacaoServico — a implementação concreta da interface —, o HttpNotificacaoRepositorio para o registro de tokens no backend, e eventuais modelos de dados como TokenFcmDto que representam a estrutura JSON esperada pela Lambda Function.

A pasta features/notificacoes/apresentacao/ contém os widgets e providers relacionados a notificações: os listeners que exibem o SnackBar em foreground, o widget de configuração de preferências de notificação e o provider PreferenciasNotificacaoNotifier que gerencia o estado das preferências do usuário (quais tipos de notificação estão habilitados).

Testando a Camada de Notificações

A separação entre a interface de domínio e a implementação Firebase facilita significativamente os testes. Para testar o SessaoNotifier (que chama registrarToken() após o login), você injeta um mock de INotificacaoRepositorio que registra as chamadas sem nenhuma dependência no Firebase. Para testar o comportamento do SnackBar de foreground, você pode usar um mock do stream onMensagemRecebida que emite mensagens sob demanda durante o teste.

O único componente que precisa de um dispositivo real ou de um emulador com Google Play Services para ser testado de ponta a ponta é o fluxo de entrega da notificação em si — desde o envio pelo Firebase Admin SDK no Python até a recepção pelo firebase_messaging no Flutter. Para esse teste, você usará o console Firebase (que oferece um painel de “Envio de mensagem de teste” onde você pode informar um token FCM específico e disparar uma notificação de teste) ou um script Python local que invoca o Firebase Admin SDK.

Reflexão sobre o Módulo no Contexto da Disciplina

O FCM é o décimo segundo tópico da ementa, e é significativo que ele venha logo após a autenticação (Módulo 11) e antes do acesso a recursos nativos (Módulo 13). A sequência não é acidental: notificações push dependem de autenticação para funcionar corretamente — sem saber quem é o usuário, o backend não pode enviar notificações direcionadas — e introduzem o conceito de permissões em tempo de execução que o Módulo 13 aprofundará com câmera, geolocalização e outros recursos sensíveis.

Olhando para trás, você pode traçar uma linha de dependência através de todos os módulos que construiu: a linguagem Dart do Módulo 02 é a base para tudo; os widgets e o gerenciamento de estado dos Módulos 03 a 07 constroem a interface; a persistência local do Módulo 08 complementa o armazenamento em nuvem; o consumo de APIs do Módulo 09 estabelece os padrões de comunicação que o backend AWS do Módulo 10 passa a servir; a autenticação do Módulo 11 protege esse backend; e as notificações deste módulo fecham o ciclo de comunicação entre o servidor e o usuário. Cada módulo amplia o sistema anterior em vez de substituí-lo — e é exatamente isso que caracteriza um projeto de software bem arquitetado.


Resumo do Módulo

Neste módulo, você implementou o sistema completo de notificações push para o aplicativo de delivery, transformando uma aplicação estática em uma experiência em tempo real que mantém o usuário informado sobre cada etapa do seu pedido.

Você começou compreendendo a arquitetura do sistema de push notifications: por que o FCM existe como intermediário, o que é um token FCM e como ele funciona como endereço de entrega de mensagens. A distinção entre mensagens de notificação e mensagens de dados é conceitual, mas com consequências práticas diretas — ela determina quem é responsável por exibir a notificação (o sistema operacional ou o código do aplicativo) e em que contextos ela funciona.

A configuração do Firebase — com o google-services.json, o plugin Gradle e o flutterfire configure — é mecânica, mas cada passo precisa ser executado corretamente. A inicialização no main.dart com o handler de background registrado antes do runApp() garante que as mensagens sejam processadas mesmo quando o aplicativo não está ativo.

Os três estados de recebimento — foreground, background e terminated — têm tratamentos distintos que você implementou usando onMessage, onBackgroundMessage, onMessageOpenedApp e getInitialMessage. A restrição do handler de background (top-level function sem acesso ao BuildContext) é uma limitação do design do Flutter que você contornou usando o armazenamento local como mecanismo de comunicação entre o isolate de background e o aplicativo quando ele volta ao foreground.

No backend, o Firebase Admin SDK para Python permite que a Lambda delivery-processar-pedido envie notificações direcionadas a um ou múltiplos dispositivos, tratando tokens inválidos (de aplicativos desinstalados) e relações multicast de forma eficiente. O UPSERT de tokens FCM no banco de dados garante que o backend sempre tenha o token mais recente de cada dispositivo.

No Módulo 13, você vai expandir as capacidades do aplicativo acessando recursos nativos do dispositivo: câmera para foto de perfil do usuário, geolocalização para sugerir endereços de entrega próximos e compartilhamento de código de referência com outros usuários. O pacote permission_handler que você verá no próximo módulo segue a mesma filosofia de respeito à privacidade do usuário que você praticou ao solicitar a permissão de notificações neste módulo.