PDM · DAW 2

Aula 21 · Semana 9 · Sexta, 6 de novembro de 2026 · 08:30 às 12:30

Workshop: localização, mapas e permissões

Teoria: fontes e precisão da localização, custo em bateria, permissões e exigências das lojas; opções de mapas. Prática (três horas): permissão tratada em todos os casos, posição atual, localidade mais próxima e mapa dos Açores com as localidades e o estado do tempo, para integrar no projeto.

Repo: pdm-aula-21

No fim desta aula consegues

De onde vem a posição

m km precisão (melhor em cima) quase sem bateria muita bateria Rede móvel 100 m a vários km bateria quase nula LocationAccuracy.low Wi-Fi 20 a 50 m, mesmo em interiores bateria baixa LocationAccuracy.medium GPS / GNSS 3 a 10 m, só com céu aberto bateria alta, fix em 10 a 30 s LocationAccuracy.high a app pede uma precisão; o sistema funde as fontes (Fused Location no Android, Core Location no iOS)
Mais precisão custa mais bateria: a app pede um nível e o sistema escolhe as fontes.
FontePrecisão típicaBateriaOnde funciona
GPS/GNSS (GPS, Galileo, GLONASS)3 a 10 m ao ar livreAlta; o primeiro fix demora 10 a 30 sCom céu aberto. Falha em interiores, garagens e ruas estreitas.
Wi-Fi20 a 50 mBaixaCidades e interiores: compara as redes visíveis com uma base de dados da Google ou da Apple.
Redes móveis100 m a vários kmMuito baixaEm todo o lado com cobertura; sabe a antena, não a rua.
Fusão (Fused Location no Android, Core Location no iOS)A que pediresAjustada ao pedidoO sistema combina as três com os sensores de movimento. É o que a tua app usa.
Bluetooth beacons1 a 5 mBaixaInteriores instrumentados: museus, centros comerciais, aeroportos.

Para os testes: a app nunca fala com o GPS; pede ao sistema uma precisão, e o sistema escolhe as fontes.

Precisão vs bateria: uma posição ou um stream

Para os testes: pede a precisão mínima que o caso de uso aguenta; precisão alta é GPS e GPS é bateria.

Analogia O GPS é uma lanterna forte e gastadora: mostra a porta certa, mas consome bateria enquanto está ligada. O Wi-Fi e a rede móvel são a luz ambiente: já está acesa, quase não custa, e chega para saber a rua, não para saber a porta. Pedir medium é usar a luz ambiente; pedir best é acender a lanterna.

Permissões de localização (1 de 2): que tipos há

PermissãoiOSAndroid
Em uso (when in use)"Ao usar a app". É a que pedes por defeito.ACCESS_FINE_LOCATION ou ACCESS_COARSE_LOCATION, só com a app visível.
Sempre (always)Pedido separado, depois de "em uso"; o iOS pergunta outra vez mais tarde se a app a usa mesmo.ACCESS_BACKGROUND_LOCATION, pedido separado (Android 10+); a partir do 11 só nas definições da app.
Precisa vs aproximadaiOS 14+: interruptor "Localização precisa" no diálogo. Aproximada dá alguns quilómetros.Android 12+: o utilizador escolhe "aproximada" mesmo que peças fine. A app recebe coarse.
Uma vez"Permitir uma vez": expira quando a app fecha."Só desta vez" (Android 11+): mesmo comportamento.

Para os testes: "sempre" e "precisa" são pedidos separados e posteriores; começa por "em uso", e por aproximada se te chegar.

Permissões de localização (2 de 2): justificar, negar, recuperar

Pedir permissão em contexto, ao tocar no botão concede recusa volta a pedir com justificação «não voltar a perguntar» Concedida while in use (por defeito) ou always: pedido à parte precisa ou aproximada getCurrentPosition() Negada o utilizador recusou podes pedir outra vez, depois de explicar o que perde (iOS: uma recusa é permanente) 2.ª recusa Android Negada para sempre request() já não abre diálogo só nas definições da app: openAppSettings() Serviço de localização desligado: estado à parte não é permissão; liga-se nas definições do sistema isLocationServiceEnabled()
Quatro casos a tratar: serviço desligado, negada, negada para sempre e concedida; só a negada admite voltar a pedir.

Para os testes: uma app tem de tratar quatro casos: serviço desligado, negada, negada para sempre, concedida.

Analogia Pedir uma permissão é pedir licença para entrar num quarto da casa do utilizador. Pede-se à porta desse quarto, com a razão à vista, e não à entrada da casa: no arranque da app ainda ninguém sabe para que serve. Se a resposta for «nunca mais», a app não pode voltar a bater à porta; a única saída é o utilizador ir às definições e abrir a porta por iniciativa própria.

Localização em segundo plano

Para os testes: localização em segundo plano só para funcionalidades centrais, com declaração nas lojas, e o utilizador vê que está a acontecer.

Geofencing

Para os testes: geofencing é feito pelo sistema operativo, poupa bateria, tem latência de minutos e limites de 100 (Android) e 20 (iOS).

Mapas (1 de 2): as opções

OpçãoCustos e chavesPlataformasNotas
Google Maps SDK (google_maps_flutter)Chave de API e conta de faturação com cartão. O mapa nativo em mobile não é cobrado; Places, Directions e Geocoding são.Android e iOSPlatform view: pesado, mas com estradas, tráfego e Street View.
Apple Maps (MapKit)Grátis, sem chave, dentro de apps iOS.Só iOS (packages comunitários)Privacidade como argumento; não resolve o Android.
OpenStreetMap + flutter_mapGrátis, sem chave. Tiles públicos com política de uso justo e atribuição obrigatória.Todas: é Dart puroPara produção, fornecedor de tiles com chave (MapTiler, Stadia, Thunderforest).
MapboxChave; grátis até um tecto mensal de carregamentos.Android, iOS, webEstilos personalizados, SDK nativo.

Mapas (2 de 2): qual usar e porquê

Para os testes: o OSM é grátis mas tem política de uso e atribuição obrigatória; o Google Maps exige chave e conta de faturação mesmo quando não cobra.

Workshop: clonar e correr

git clone https://github.com/pdm-daw-2026/pdm-aula-21.git
cd pdm-aula-21/starter
flutter pub get
flutter run
BlocoHorasPassos
Permissões e posição09:20 a 10:151 a 7
Intervalo10:15 a 10:30
Mapa10:30 a 11:308 a 10
Integrar no projeto11:30 a 12:3011 e exercícios

Passo 1: packages e requisitos de plataforma

No projeto do grupo, os mesmos packages entram com um comando:

flutter pub add geolocator permission_handler flutter_map latlong2 url_launcher

Passo 2: permissões no Android

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <!-- Rede: tiles do mapa e API do curso -->
    <uses-permission android:name="android.permission.INTERNET" />

    <!-- Localização em uso. Declara as duas: o Android 12+ deixa
         o utilizador escolher "aproximada", e a app recebe coarse. -->
    <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
    <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />

    <!-- NÃO acrescentar sem razão: obriga a declaração no Google Play.
    <uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
    -->

    <application
        android:label="Tempo Açores"
        android:icon="@mipmap/ic_launcher">
        ...
    </application>
</manifest>

Sem INTERNET o mapa fica cinzento em release: em debug o Flutter acrescenta-a por ti, e o erro só aparece no build final.

Passo 3: permissões no iOS

<key>NSLocationWhenInUseUsageDescription</key>
<string>Usamos a tua posição para mostrar a previsão da localidade mais próxima.</string>
<!-- Só se um dia pedires "sempre": -->
<!-- <key>NSLocationAlwaysAndWhenInUseUsageDescription</key> -->

O permission_handler só compila o código de cada permissão que ativares, para a revisão da Apple não ver permissões que não usas:

post_install do |installer|
  installer.pods_project.targets.each do |target|
    flutter_additional_ios_build_settings(target)
    target.build_configurations.each do |config|
      config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [
        '$(inherited)',
        'PERMISSION_LOCATION=1',
      ]
    end
  end
end

Depois: cd ios && pod install. Quem não tem Mac salta este passo e testa só em Android.

Passo 4: pedir a permissão e tratar todos os resultados

import 'package:geolocator/geolocator.dart';
import 'package:permission_handler/permission_handler.dart';

enum EstadoLocalizacao { pronta, servicoDesligado, negada, negadaParaSempre }

class ServicoLocalizacao {
  /// Garante que podemos pedir uma posição. Nunca lança; devolve o estado.
  Future<EstadoLocalizacao> prepararPermissao() async {
    if (!await Geolocator.isLocationServiceEnabled()) {
      return EstadoLocalizacao.servicoDesligado; // localização desligada no sistema
    }
    var estado = await Permission.locationWhenInUse.status;
    if (estado.isDenied) {
      estado = await Permission.locationWhenInUse.request(); // diálogo do sistema
    }
    return switch (estado) {
      PermissionStatus.granted || PermissionStatus.limited => EstadoLocalizacao.pronta,
      PermissionStatus.permanentlyDenied || PermissionStatus.restricted =>
        EstadoLocalizacao.negadaParaSempre,
      _ => EstadoLocalizacao.negada,
    };
  }
}

Passo 5: o ecrã reage a cada estado

Future<void> _pedirLocalizacao(BuildContext context) async {
  final estado = await context.read<ServicoLocalizacao>().prepararPermissao();
  if (!context.mounted) return; // o utilizador pode ter saído do ecrã

  switch (estado) {
    case EstadoLocalizacao.pronta:
      await context.read<LocalizacaoNotifier>().atualizar();
    case EstadoLocalizacao.servicoDesligado:
      _aviso(context, 'A localização está desligada no telemóvel.',
          acao: 'Ligar', aoTocar: Geolocator.openLocationSettings);
    case EstadoLocalizacao.negadaParaSempre:
      _aviso(context, 'Sem permissão. Podes mudar isso nas definições da app.',
          acao: 'Definições', aoTocar: openAppSettings);
    case EstadoLocalizacao.negada:
      _aviso(context, 'Sem a tua posição, mostramos Ponta Delgada.');
  }
}

void _aviso(BuildContext context, String texto,
    {String? acao, Future<bool> Function()? aoTocar}) {
  ScaffoldMessenger.of(context).showSnackBar(SnackBar(
    content: Text(texto),
    action: acao == null ? null : SnackBarAction(label: acao, onPressed: () => aoTocar?.call()),
  ));
}

Permissões: o que interessa

Exercício 1 Completa os TODOs 1 a 4. Testa os quatro caminhos no telemóvel: aceita; nega uma vez; nega com "não voltar a perguntar" (ou no iOS, nega uma vez); desliga a localização no sistema. Em cada um, a app tem de dizer algo útil e não crashar. Extra: no Android 12+, escolhe "aproximada" e vê o que Geolocator.getLocationAccuracy() devolve.

Passo 6: a posição atual (e um stream para depois)

import 'dart:async'; // TimeoutException

// ...dentro de ServicoLocalizacao
Future<Position> posicaoAtual() async {
  try {
    return await Geolocator.getCurrentPosition(
      locationSettings: const LocationSettings(
        accuracy: LocationAccuracy.medium, // Wi-Fi chega: ~100 m
        timeLimit: Duration(seconds: 10),
      ),
    );
  } on TimeoutException {
    // Sem fix em 10 s (garagem, interior): a última conhecida serve
    final ultima = await Geolocator.getLastKnownPosition();
    if (ultima != null) return ultima;
    rethrow;
  }
}

/// Para navegação, ou para atualizar enquanto o utilizador anda.
Stream<Position> posicoes() => Geolocator.getPositionStream(
      locationSettings: const LocationSettings(
        accuracy: LocationAccuracy.high,
        distanceFilter: 50, // só emite depois de 50 m percorridos
      ),
    );

Passo 7: a localidade mais próxima

import 'package:geolocator/geolocator.dart';

typedef Proximidade = ({Localidade localidade, double metros});

Proximidade maisProxima(Position pos, List<Localidade> localidades) {
  assert(localidades.isNotEmpty, 'a lista vem de GET /v1/locations');
  var melhor = localidades.first;
  var menor = double.infinity;
  for (final l in localidades) {
    final d = Geolocator.distanceBetween(
      pos.latitude, pos.longitude, l.latitude, l.longitude,
    );
    if (d < menor) {
      menor = d;
      melhor = l;
    }
  }
  return (localidade: melhor, metros: menor);
}

String formataDistancia(double metros) => metros < 1000
    ? '${metros.round()} m'
    : '${(metros / 1000).toStringAsFixed(1)} km';

Posição e distância: o que interessa

Exercício 2 Completa os TODOs 5 a 7 e faz passar os testes de maisProxima e formataDistancia (flutter test). No ecrã inicial, um cartão "Estás a 3,2 km de Ponta Delgada" com botão para abrir o detalhe. Trata o caso "fora dos Açores".

Passo 8: o mapa com tiles do OpenStreetMap

Dentro de _MapaLocalidadesState, o State de um widget que recebe a lista de localidades. Imports: flutter_map e latlong2.

final _controlador = MapController();
static final _acores = LatLngBounds(
  const LatLng(36.7, -31.5), // sudoeste: a sul das Flores
  const LatLng(39.9, -24.6), // nordeste: a leste de Santa Maria
);

@override
Widget build(BuildContext context) {
  return FlutterMap(
    mapController: _controlador,
    options: MapOptions(
      initialCenter: const LatLng(38.4, -28.0),
      initialZoom: 6.5,
      minZoom: 5,
      maxZoom: 15,
      cameraConstraint: CameraConstraint.contain(bounds: _acores),
    ),
    children: [
      TileLayer(
        urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
        userAgentPackageName: 'pt.uac.tempoacores',
      ),
      _marcadores(context), // passo 9
      _atribuicao(),
    ],
  );
}

Passo 9: marcadores com o estado do tempo e atribuição

Widget _marcadores(BuildContext context) {
  final previsoes = context.watch<PrevisaoNotifier>().porLocalidade; // Map<int, Previsao>
  return MarkerLayer(
    markers: [
      for (final l in widget.localidades)
        Marker(
          point: LatLng(l.latitude, l.longitude),
          width: 44,
          height: 44,
          child: GestureDetector(
            onTap: () => context.push('/localidade/${l.id}'),
            child: Tooltip(
              message: l.nome,
              child: Icon(iconePara(previsoes[l.id]?.tipo), size: 36),
            ),
          ),
        ),
    ],
  );
}

Widget _atribuicao() => RichAttributionWidget(attributions: [
      TextSourceAttribution('OpenStreetMap contributors',
          onTap: () => launchUrl(Uri.parse('https://openstreetmap.org/copyright'))),
    ]);

Mapa: o que interessa

Exercício 3 Completa os TODOs 8 e 9. O mapa mostra as localidades da API com o ícone do tempo atual; tocar num marcador abre o detalhe. Extra: escreve a temperatura máxima por baixo do ícone, num Column dentro do marcador.

Passo 10: centrar no utilizador

LatLng? _minhaPosicao;

Future<void> _irParaMim() async {
  final servico = context.read<ServicoLocalizacao>();
  final estado = await servico.prepararPermissao();
  if (!mounted) return;
  if (estado != EstadoLocalizacao.pronta) {
    ScaffoldMessenger.of(context).showSnackBar(SnackBar(
      content: const Text('Sem permissão de localização.'),
      action: SnackBarAction(label: 'Definições', onPressed: () => openAppSettings()),
    ));
    return;
  }
  final pos = await servico.posicaoAtual();
  final aqui = LatLng(pos.latitude, pos.longitude);
  setState(() => _minhaPosicao = aqui);
  _controlador.move(aqui, 10); // fora dos Açores? o cameraConstraint segura
}

// No MarkerLayer, um marcador para o utilizador (if-case do Dart 3):
//   if (_minhaPosicao case final eu?)
//     Marker(point: eu, child: const Icon(Icons.person_pin_circle, size: 40)),
// No Scaffold do ecrã:
//   floatingActionButton: FloatingActionButton(
//     onPressed: _irParaMim, child: const Icon(Icons.my_location)),

Passo 11: integrar no projeto como separador Mapa

// Dentro do ShellRoute que tem a NavigationBar (aula 9):
GoRoute(path: '/mapa', builder: (context, state) => const MapaEcra()),

// Na NavigationBar, um destino novo entre Previsão e Avisos:
NavigationDestination(icon: Icon(Icons.map_outlined), label: 'Mapa'),
// e no onDestinationSelected: context.go(['/', '/mapa', '/avisos', '/definicoes'][i])

// Em main.dart, os providers que os ecrãs vão ler:
MultiProvider(
  providers: [
    Provider(create: (_) => ServicoLocalizacao()),
    ChangeNotifierProvider(
      create: (c) => LocalizacaoNotifier(c.read(), c.read()), // serviço + repositório
    ),
  ],
  child: const TempoApp(),
)
Exercício 4 TODOs 10 e 11: separador Mapa no projeto do grupo, com marcadores, botão "A minha posição" e o cartão da localidade mais próxima no ecrã inicial. Abre um PR por pessoa: um faz o mapa, outro o serviço e o notifier, outro a integração e os testes. Extra: ouve posicoes() e move o marcador do utilizador.

Problemas frequentes

SintomaCausa provávelSolução
No iOS o diálogo de permissão nunca apareceFalta a chave no Info.plist ou a macro PERMISSION_LOCATION no PodfilePasso 3, depois pod install e rebuild completo
Mapa cinzento, sem tilesSem rede, sem INTERNET no manifesto (release), ou userAgentPackageName em faltaConfirma os três; vê o log por respostas 403 ou 429 do OSM
A posição aparece na CalifórniaEmulador com a localização por defeito da GoogleExtended controls → Location, ou usa o telemóvel
permanentlyDenied logo ao primeiro pedidoJá foi negada antes (no Android, duas vezes; no iOS, uma)Definições da app → Permissões → Localização; ou reinstala a app
getCurrentPosition nunca responde dentro da salaPrecisão high exige GPS e não há céumedium, timeLimit e getLastKnownPosition como no passo 6

Até segunda

Para fazer

  • Mapa no projeto com marcadores e localidade mais próxima: PRs revistos e merged até segunda.
  • Testa a app no telemóvel de um colega com a permissão negada: a app continua utilizável?
  • Segunda: notificações locais e deep links. O separador Mapa e a rota /localidade/:id vão ser precisos.

Para ler