Módulo 13 — Exercícios: Acesso a Recursos Nativos e Permissões

Neste módulo você mergulhou em uma dimensão do desenvolvimento mobile que não tem equivalente no desenvolvimento web: o acesso direto ao hardware do dispositivo que o usuário carrega no bolso. Você entendeu como o Android evoluiu seu modelo de permissões ao longo das versões, aprendeu a tratar os quatro estados possíveis de qualquer permissão — granted, denied, permanentlyDenied e restricted — e implementou serviços completos de geolocalização, captura de imagem e compartilhamento de conteúdo. Os três exercícios a seguir percorrem esses temas de forma progressiva no contexto do aplicativo de delivery. O primeiro exige que você domine o ciclo de vida das permissões e construa um serviço genérico de gerenciamento. O segundo integra localização e rastreamento contínuo com o sistema de estado via Provider. O terceiro combina câmera, envio de arquivo para o backend e persistência local em SQLite, reunindo conhecimentos de vários módulos anteriores. Leia cada enunciado com atenção antes de escrever qualquer linha, pois as decisões de design neste módulo afetam diretamente a experiência do usuário e o respeito à sua privacidade.


Exercício 1

PermissaoServico: o ciclo de vida completo das permissões no delivery

Antes de acessar qualquer hardware, é preciso saber gerenciar permissões corretamente

Todo o acesso a recursos nativos do dispositivo começa com permissões. Não importa se você quer abrir a câmera, ler a posição GPS ou enviar notificações: o sistema operacional exige que o aplicativo solicite autorização explícita do usuário antes de acessar qualquer recurso sensível. Mais do que simplesmente chamar permission.request(), um gerenciamento correto de permissões significa compreender que cada permissão pode estar em um de quatro estados distintos e que cada estado exige uma resposta diferente do aplicativo. Este exercício trata exatamente disso: você vai construir um PermissaoServico robusto que abstrai toda a lógica de solicitação e tratamento de permissões, e vai integrá-lo a uma tela demonstrativa do delivery que cobre os três recursos mais relevantes para o aplicativo — localização, câmera e notificações.

O contexto prático importa aqui. Imagine que o usuário do seu aplicativo de delivery toca na opção “Usar localização atual” no campo de endereço de entrega. Nesse momento, três cenários radicalmente diferentes podem ocorrer: o usuário pode ainda não ter tomado uma decisão sobre essa permissão, pode tê-la negado anteriormente (e ainda é possível pedir de novo), ou pode tê-la negado permanentemente ao marcar “Não perguntar novamente”. Cada um desses cenários exige uma resposta diferente do aplicativo — exibir o diálogo do sistema, mostrar uma explicação ao usuário, ou abrir as configurações do sistema operacional. Um aplicativo que trata todos esses cenários com a mesma resposta genérica confunde o usuário e perde a confiança dele.

O primeiro componente que você deve implementar é um enum chamado RecursoNativo com três valores: localizacao, camera e notificacao. Esse enum serve como uma abstração sobre o enum Permission do permission_handler, permitindo que o restante do aplicativo fale em termos de domínio — recursos do delivery — em vez de termos técnicos de plataforma. O segundo componente é o próprio PermissaoServico, que recebe uma instância do permission_handler de forma indireita (você pode trabalhar com as extensões do pacote diretamente). Ele deve ter um método permissaoParaRecurso(RecursoNativo recurso) que retorna a Permission correspondente: Permission.location para localizacao, Permission.camera para camera e Permission.notification para notificacao.

O método central do serviço é solicitarPermissaoParaRecurso(RecursoNativo recurso), que retorna um Future<PermissionStatus>. Ele deve verificar primeiro o status atual com permissao.status. Se o status já for granted ou limited, retorna imediatamente sem exibir nenhum diálogo — mostrar um diálogo para uma permissão já concedida é um anti-padrão que confunde o usuário. Se o status for permanentlyDenied ou restricted, retorna o status diretamente sem chamar .request(), pois chamar .request() nesses estados não produz nenhum efeito visível e pode levar o desenvolvedor a pensar erroneamente que a chamada falhou. Somente se o status for denied ou notDetermined (em plataformas que suportam esse estado) o método deve chamar permissao.request() e retornar o resultado.

O método tratarResultadoPermissao deve receber o PermissionStatus obtido e um conjunto de callbacks nomeados opcionais: onGranted, onDenied, onPermanentlyDenied e onRestricted. Para cada status, invoca o callback correspondente, se ele tiver sido fornecido. A assinatura do método deve ser assíncrona para permitir que os callbacks sejam Future<void> Function(). O método precisaDeRationale(RecursoNativo recurso) verifica o status atual da permissão e retorna true se o status for denied — indicando que uma justificativa adicional seria apropriada antes de solicitar novamente. Esse método implementa o comportamento recomendado pelo Android: quando o usuário negou a permissão uma vez, o aplicativo deve explicar por que precisa dela antes de solicitar de novo.

O método abrirConfiguracoesSistema() deve chamar openAppSettings() do permission_handler e retornar o resultado booleano que indica se as configurações foram abertas com sucesso. Esse método é chamado quando a permissão está permanentemente negada e o único caminho para o usuário concedê-la é através das configurações do sistema.

O terceiro componente é uma TelaGerenciarPermissoes, um StatefulWidget que exibe o status atual de cada um dos três recursos nativos e permite ao usuário solicitá-los individualmente. Para cada recurso, a tela deve exibir o nome amigável do recurso, um indicador visual do status atual (você pode usar um Icon com cores diferentes para cada estado: verde para granted, amarelo para denied, vermelho para permanentlyDenied, cinza para restricted) e um botão contextual que varia conforme o estado atual da permissão.

O botão contextual deve seguir esta lógica: se o status for granted, exibe “Permissão concedida” desabilitado; se for denied e o rationale for necessário, exibe “Por que precisamos disso?” que, ao ser tocado, exibe um AlertDialog explicando o motivo e depois chama solicitarPermissaoParaRecurso; se for denied sem rationale necessário (primeira solicitação), exibe “Solicitar permissão” que chama diretamente solicitarPermissaoParaRecurso; se for permanentlyDenied, exibe “Abrir configurações” que chama abrirConfiguracoesSistema(); se for restricted, exibe “Indisponível” desabilitado. Após qualquer ação que modifica o status, a tela deve recarregar os status de todos os recursos com setState.

Os textos de rationale para cada recurso devem ser: para localização, “O aplicativo usa sua localização para sugerir automaticamente o endereço de entrega, eliminando a necessidade de digitá-lo manualmente em cada pedido.”; para câmera, “O aplicativo usa sua câmera para atualizar sua foto de perfil e para que o entregador registre o comprovante de entrega quando você não está disponível.”; para notificação, “O aplicativo usa notificações para informar o status do seu pedido em tempo real, desde a confirmação até a chegada do entregador.”

Implemente todos os componentes em um único arquivo g_ex1.dart, que deve conter também uma função main com um MaterialApp simples exibindo a TelaGerenciarPermissoes. Não instancie PermissaoServico diretamente no widget — forneça-o via construtor ou via Provider registrado no MaterialApp.

Antes de implementar, reflita sobre três questões que vão além da sintaxe. Por que verificar o status da permissão antes de chamar .request() é preferível a chamar .request() diretamente em todos os casos? O que acontece com a experiência do usuário quando o aplicativo exibe o diálogo do sistema para uma permissão que o usuário já concedeu? Por que o estado permanentlyDenied impede que o diálogo do sistema seja exibido, e qual é a única saída para o usuário nesse cenário?

O enum Permission do permission_handler usa Permission.notification para notificações no Android 13 e superior. Em versões anteriores do Android, a permissão POST_NOTIFICATIONS não existia — as notificações eram concedidas automaticamente. O comportamento do permission_handler em dispositivos com Android abaixo do nível 33 é retornar granted automaticamente para Permission.notification, pois a permissão não é exigida pelo sistema. Não adicione código especial para isso: o pacote já cuida dessa diferença de plataforma internamente. Basta declarar a permissão no AndroidManifest.xml e usar a API normalmente — o pacote garante que o comportamento seja correto em todas as versões suportadas.

O que deve ser entregue: um arquivo chamado g_ex1.dart, onde g é o nome do seu grupo.


Exercício 2

LocalizacaoNotifier: rastreamento em tempo real integrado com Provider

A posição do entregador deve aparecer na tela sem que o usuário precise atualizar manualmente

Uma das funcionalidades mais valorizadas por usuários de aplicativos de delivery é o acompanhamento em tempo real do entregador. O usuário que acabou de fazer um pedido quer saber onde está o entregador, quanto tempo falta para a entrega chegar, e se o entregador está se movendo ou parado. Essas informações dependem de uma cadeia técnica específica: o dispositivo do entregador obtém atualizações contínuas de posição via GPS, o aplicativo recebe cada nova posição via um Stream, e a interface se atualiza automaticamente sempre que uma nova posição chega. Este exercício implementa essa cadeia completa no contexto do aplicativo de delivery, integrando o geolocator com o sistema de estado baseado em Provider.

O desafio técnico central deste exercício não é a obtenção da posição — você já viu como fazer isso no material. O desafio é gerenciar corretamente o ciclo de vida do Stream de posições dentro de um ChangeNotifier. Diferente de uma Future que resolve uma vez e termina, um Stream de localização permanece ativo indefinidamente, emitindo novas posições enquanto a subscrição estiver aberta. Isso cria responsabilidades específicas: a subscrição deve ser cancelada quando o Notifier é descartado, não deve ser reiniciada se já estiver ativa, e deve lidar com erros que chegam através do próprio stream — como quando o serviço de localização é desligado pelo usuário enquanto o rastreamento está em andamento.

O primeiro componente que você deve implementar é o enum EstadoLocalizacao com os valores inicial, verificandoPermissao, permissaoNegada, permissaoPermanentementeNegada, servicoDesabilitado, obtendoPosicao, posicaoObtida e rastreandoContinuamente. Esses estados mapeiam todos os cenários possíveis durante o ciclo de vida da localização no aplicativo.

O segundo componente é a classe PosicaoLocal com os campos finais latitude (double), longitude (double), precisaoMetros (double) e atualizadaEm (DateTime). Adicione um método formatarCoordenadas() que retorna uma String no formato "${latitude.toStringAsFixed(6)}, ${longitude.toStringAsFixed(6)}" — útil para exibir na interface de depuração durante o desenvolvimento.

O terceiro e principal componente é o LocalizacaoNotifier, que deve estender ChangeNotifier. Ele recebe pelo construtor uma instância do LocalizacaoServico (ou equivalente — você pode definir uma interface simples ILocalizacaoServico com os métodos necessários, o que facilita a substituição por um mock em testes). Os campos internos do Notifier devem incluir: EstadoLocalizacao _estado (inicializado como inicial), PosicaoLocal? _posicaoAtual, String? _enderecoSugerido, StreamSubscription<Position>? _subscricaoRastreamento e String? _mensagemErro.

O método obterPosicaoUnica() implementa a lógica de obtenção de posição uma única vez, sem iniciar o rastreamento contínuo. Ele deve: mudar o estado para verificandoPermissao e notificar; verificar se o serviço de localização está habilitado com Geolocator.isLocationServiceEnabled(); se desabilitado, mudar para servicoDesabilitado, definir _mensagemErro e notificar; verificar a permissão com Geolocator.checkPermission(); se denied, solicitar com Geolocator.requestPermission(); se ainda denied após a solicitação, mudar para permissaoNegada e notificar; se deniedForever, mudar para permissaoPermanentementeNegada e notificar; se concedida, mudar para obtendoPosicao e notificar; obter a posição com Geolocator.getCurrentPosition() com LocationAccuracy.high e timeout de 15 segundos; ao obter a posição, atualizar _posicaoAtual, mudar para posicaoObtida e notificar.

O método iniciarRastreamentoContinuo() deve verificar se _subscricaoRastreamento já está ativa — se sim, retorna sem fazer nada para evitar subscrições duplicadas. Caso contrário, verifica as permissões da mesma forma que obterPosicaoUnica(). Se as permissões estiverem concedidas, muda para rastreandoContinuamente, notifica, e assina o stream de posições com Geolocator.getPositionStream() configurado com LocationAccuracy.high e distanceFilter: 10 (metros). Para cada nova posição emitida pelo stream, atualiza _posicaoAtual e chama notifyListeners(). No callback de erro do stream, define _mensagemErro e notifica. O método pararRastreamento() deve cancelar a subscrição, definir _subscricaoRastreamento como nulo, mudar o estado de volta para posicaoObtida (se havia uma posição) ou inicial (se não havia), e notificar.

O método dispose() deve cancelar _subscricaoRastreamento antes de chamar super.dispose(). Esse passo é obrigatório: um StreamSubscription não cancelado continua executando seu callback mesmo após o Notifier ter sido descartado, o que produz chamadas a notifyListeners() em um objeto já descartado e lança uma exceção no framework.

O quarto componente é a TelaRastreamentoEntrega, um StatelessWidget que usa Consumer<LocalizacaoNotifier>. A tela deve exibir um Column com os seguintes elementos na ordem: um card com o título “Posição do Entregador” e um switch no cabeçalho que ativa e desativa o rastreamento contínuo chamando notifier.iniciarRastreamentoContinuo() e notifier.pararRastreamento(); um bloco condicional que exibe CircularProgressIndicator nos estados verificandoPermissao e obtendoPosicao; um card com a posição atual (latitude, longitude e precisão) quando _posicaoAtual não for nula; um card com o endereço sugerido quando disponível; e um card de erro com o texto em _mensagemErro quando o estado for servicoDesabilitado, permissaoNegada ou permissaoPermanentementeNegada. Quando o estado for permissaoPermanentementeNegada, exiba adicionalmente um TextButton “Abrir configurações” que chama openAppSettings() do permission_handler.

O quinto componente é a TelaEnderecoEntrega, que demonstra o caso de uso de obtenção de posição única para sugerir o endereço de entrega. Ela deve ter um TextFormField para o endereço (editável pelo usuário) e um IconButton com ícone de localização ao lado do campo. Quando o botão for tocado, deve chamar notifier.obterPosicaoUnica() e, quando o estado chegar a posicaoObtida, preencher automaticamente o TextFormField com o endereço sugerido pelo notifier.enderecoSugerido, se disponível.

Implemente todos os componentes em um único arquivo g_ex2.dart, com uma função main que configura o MultiProvider com o LocalizacaoNotifier e exibe a TelaRastreamentoEntrega como tela inicial.

Reflita sobre três questões que revelam a complexidade real do gerenciamento de localização. O que acontece com a bateria do dispositivo se o rastreamento contínuo com LocationAccuracy.high for deixado ativo enquanto o aplicativo está em segundo plano? Por que verificar if (_subscricaoRastreamento != null) return; no início de iniciarRastreamentoContinuo() é necessário, e o que aconteceria se essa verificação estivesse ausente e o usuário tocasse várias vezes no switch de rastreamento? Qual é a diferença prática entre distanceFilter: 10 e distanceFilter: 100 em um cenário de entregador percorrendo uma rota urbana com semáforos?

O Geolocator.getPositionStream() emite erros no stream em certas condições — por exemplo, quando o serviço de localização é desabilitado pelo usuário enquanto o rastreamento está ativo, ou quando o GPS perde o sinal em um túnel por tempo superior ao timeout configurado. Esses erros chegam pelo callback onError da subscrição, não como exceções lançadas diretamente. Se você não fornecer um handler de erros ao chamar .listen(), esses erros serão propagados como exceções não capturadas e derrubam o aplicativo. O handler de erros deve sempre definir _mensagemErro, mudar o estado para um estado de erro e notificar — nunca deve relançar a exceção, pois o contexto de um callback de stream não tem um try/catch superior que possa capturá-la de forma controlada.

O que deve ser entregue: um arquivo chamado g_ex2.dart, onde g é o nome do seu grupo.


Exercício 3

ComprovanteFotoServico: câmera, upload e persistência local integrados

Um comprovante de entrega combina câmera, rede e banco de dados local em uma única operação

Há um cenário específico no delivery que combina três recursos distintos em uma única operação: quando o entregador chega no endereço e o cliente não está disponível para receber o pedido pessoalmente, o entregador precisa registrar uma foto do local de entrega como comprovante. Essa foto é tirada com a câmera do dispositivo, enviada ao backend como prova da entrega, e armazenada localmente em um histórico que o entregador pode consultar mesmo offline. Cada uma dessas três etapas — câmera, upload e persistência — já foi abordada separadamente em módulos anteriores. Este exercício as une em um fluxo coeso e completo, e o desafio está precisamente em orquestrar as três de forma que falhas em qualquer etapa sejam tratadas adequadamente, sem deixar o sistema em um estado inconsistente.

O caso de uso que você vai implementar é o seguinte: o entregador abre a tela de comprovante de entrega, toca no botão “Tirar foto”, o aplicativo verifica e solicita a permissão de câmera, abre o image_picker em modo câmera, o entregador fotografa o local, o aplicativo exibe a foto capturada em uma miniatura de pré-visualização, o entregador toca em “Confirmar entrega”, o aplicativo envia a foto ao backend via multipart/form-data, e após a confirmação do servidor, persiste localmente um registro do comprovante (com o ID do pedido, o caminho local da foto, o timestamp e o ID da resposta do servidor) em uma tabela SQLite. A tela também exibe o histórico de comprovantes registrados anteriormente, listando cada entrada com a miniatura da foto, o ID do pedido e a data.

Este exercício reúne permission_handler, image_picker, http (para multipart upload) e sqflite — todos pacotes que você já usou em módulos anteriores. O que é novo é a composição deles em um fluxo único e robusto.

O primeiro componente que você deve implementar é a entidade ComprovanteEntrega com os campos: id (int, autoincremento no banco), pedidoId (String), caminhoFotoLocal (String, o caminho no sistema de arquivos do dispositivo), timestampEnvio (DateTime), idRespostaServidor (String, o identificador retornado pelo backend após o upload bem-sucedido) e enviadoComSucesso (bool). Esse campo enviadoComSucesso é fundamental: permite distinguir comprovantes que foram fotografados mas não puderam ser enviados — por exemplo, por falta de conexão — daqueles que chegaram ao servidor com sucesso.

O segundo componente é o ComprovanteRepositorioLocal, que encapsula todas as operações de persistência SQLite. O método inicializar() deve chamar openDatabase() com a query de criação da tabela comprovantes_entrega com as colunas correspondentes aos campos da entidade. O método salvarComprovante(ComprovanteEntrega comprovante) deve inserir um novo registro e retornar o id gerado pelo banco. O método listarTodos() deve retornar Future<List<ComprovanteEntrega>> com os registros ordenados por timestampEnvio decrescente. O método marcarComoEnviado(int id, String idRespostaServidor) deve executar um UPDATE definindo enviado_com_sucesso = 1 e id_resposta_servidor = ? para o registro com o id informado. Esse método permite que comprovantes salvos localmente durante uma falha de rede sejam marcados como enviados quando a conexão for restaurada.

O terceiro componente é o ComprovanteFotoServico, que orquestra a câmera e o upload. Ele recebe pelo construtor um PermissaoServico (do exercício anterior ou uma versão simplificada), um ImagePicker e uma instância de http.Client. O método capturarFotoComprovante() deve: solicitar a permissão de câmera via PermissaoServico.solicitarPermissaoParaRecurso(RecursoNativo.camera); se negada, retornar um resultado do tipo PermissaoNegadaResultado; se concedida, chamar _picker.pickImage(source: ImageSource.camera, imageQuality: 85, maxWidth: 1280); se o usuário cancelar, retornar SelecaoCanceladaResultado; se a foto for obtida, retornar FotoCapturadaResultado(xFile: arquivo). Use um sealed class ResultadoCaptura com subclasses FotoCapturadaResultado, SelecaoCanceladaResultado e PermissaoNegadaResultado para modelar os três casos.

O método enviarFotoParaBackend(String caminhoFoto, String pedidoId) deve: criar um http.MultipartRequest com método POST para o endpoint ${AppConfig.urlBaseApi}/comprovantes; adicionar o campo de formulário pedido_id com o valor de pedidoId; adicionar o arquivo via http.MultipartFile.fromPath('foto', caminhoFoto, contentType: MediaType('image', 'jpeg')); enviar a requisição com _cliente.send(request) e aguardar http.Response.fromStream(resposta); se o status for 200 ou 201, extrair o campo "id" do JSON da resposta e retornar esse valor como String; se o status for outro, lançar uma UploadFalhouException com o código HTTP e o corpo da resposta. Declare class UploadFalhouException implements Exception com campos int statusCode e String corpo.

O quarto componente é o ComprovanteNotifier, que estende ChangeNotifier e coordena a câmera, o upload e a persistência. Ele recebe pelo construtor o ComprovanteFotoServico, o ComprovanteRepositorioLocal e o pedidoId (String) para o qual o comprovante está sendo registrado. O campo XFile? _fotoCapturarada expõe a foto atual via getter, e List<ComprovanteEntrega> _historico expõe o histórico. O enum EstadoComprovante deve ter os valores inicial, capturandoFoto, fotoCapturada, enviando, enviado, falhaPermissao e falhaUpload.

O método tirarFoto() deve mudar para capturandoFoto, chamar _servico.capturarFotoComprovante(), e com base no resultado: se FotoCapturadaResultado, armazenar a foto e mudar para fotoCapturada; se PermissaoNegadaResultado, mudar para falhaPermissao; se SelecaoCanceladaResultado, voltar para inicial. O método confirmarEntrega() deve: verificar que _fotoCapturarada != null; mudar para enviando; salvar primeiro um registro local com enviadoComSucesso = false (isso garante que mesmo se o upload falhar, o comprovante local existe); tentar chamar _servico.enviarFotoParaBackend(); se bem-sucedido, chamar _repositorio.marcarComoEnviado() com o ID retornado e mudar para enviado; se lançar UploadFalhouException, mudar para falhaUpload sem reverter o registro local — o registro local permanece com enviadoComSucesso = false e pode ser sincronizado depois. O método carregarHistorico() deve chamar _repositorio.listarTodos() e atualizar _historico.

O quinto componente é a TelaComprovanteEntrega, um StatelessWidget com Consumer<ComprovanteNotifier>. A tela deve ter dois modos visuais. No modo de captura (estados inicial, capturandoFoto, fotoCapturada, enviando, enviado e falhaUpload), exibe: uma área de pré-visualização de 200x200 pixels que mostra a foto capturada via Image.file(File(notifier.fotoCapturada!.path)) quando disponível, ou um Container cinza com um ícone de câmera quando não há foto; dois botões — “Tirar foto” (habilitado nos estados inicial e fotoCapturada) e “Confirmar entrega” (habilitado apenas no estado fotoCapturada); e um indicador de progresso sobreposto quando o estado for enviando. Em falhaUpload, exibe um SnackBar com a mensagem de erro e mantém o botão “Tentar novamente” habilitado. No modo de histórico, acessível via DefaultTabController com duas abas, exibe a lista de comprovantes anteriores via _buildCardComprovante(ComprovanteEntrega c), que mostra a miniatura da foto (Image.file com width de 80 pixels), o ID do pedido, a data formatada e um Icon verde se enviado com sucesso ou amarelo se pendente.

Implemente todos os componentes em um único arquivo g_ex3.dart, com uma função main que configura o MultiProvider com o ComprovanteNotifier (usando um pedidoId fixo de "PED-20260327-001" para demonstração) e exibe a TelaComprovanteEntrega.

Reflita sobre três decisões de design que têm consequências práticas concretas neste exercício. Por que salvar o registro local com enviadoComSucesso = false antes de tentar o upload — em vez de salvar somente após a confirmação do servidor — é a abordagem correta em termos de resiliência? O que aconteceria com a experiência do entregador se, em uma área de sinal fraco, o upload falhasse e o comprovante não tivesse sido salvo localmente? Por que usar http.MultipartRequest para enviar a foto é preferível a ler os bytes do arquivo, convertê-los para base64 e enviar como JSON?

O método http.MultipartFile.fromPath() lê o arquivo do sistema de arquivos de forma assíncrona e o inclui diretamente no corpo da requisição multipart. Para que isso funcione, o caminho retornado pelo image_picker — que é o XFile.path — deve ser um caminho válido no sistema de arquivos do dispositivo no momento do envio. Em geral, o image_picker armazena a foto em um diretório temporário cujos arquivos podem ser apagados pelo sistema operacional sob pressão de memória. Para evitar que a foto seja apagada antes de ser enviada, copie o arquivo para o diretório de documentos do aplicativo imediatamente após a captura, usando path_provider para obter o diretório e File.copy() para a cópia. Assim, o arquivo persistirá independentemente da política de limpeza do diretório temporário.

O que deve ser entregue: um arquivo chamado g_ex3.dart, onde g é o nome do seu grupo.