Aula 21 · Semana 9 · Sexta, 6 de novembro de 2026 · 08:30 às 12:30
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.
| Fonte | Precisão típica | Bateria | Onde funciona |
|---|---|---|---|
| GPS/GNSS (GPS, Galileo, GLONASS) | 3 a 10 m ao ar livre | Alta; o primeiro fix demora 10 a 30 s | Com céu aberto. Falha em interiores, garagens e ruas estreitas. |
| Wi-Fi | 20 a 50 m | Baixa | Cidades e interiores: compara as redes visíveis com uma base de dados da Google ou da Apple. |
| Redes móveis | 100 m a vários km | Muito baixa | Em todo o lado com cobertura; sabe a antena, não a rua. |
| Fusão (Fused Location no Android, Core Location no iOS) | A que pedires | Ajustada ao pedido | O sistema combina as três com os sensores de movimento. É o que a tua app usa. |
| Bluetooth beacons | 1 a 5 m | Baixa | Interiores 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.
LocationAccuracy no geolocator): lowest/low usam redes, quilómetros, quase sem bateria; medium usa Wi-Fi, cerca de 100 m; high/best/bestForNavigation ligam o GPS, metros, consumo de bateria elevado.getCurrentPosition): "onde estou agora". Liga as fontes, obtém um fix, desliga. É o que uma app de tempo precisa.getPositionStream): posições contínuas enquanto o utilizador se move. Navegação, corrida, entregas. Com distanceFilter para não emitir a cada metro.timeLimit para não esperar para sempre, e getLastKnownPosition para usar a última posição que o sistema tem em cache.medium chega e poupa bateria.Para os testes: pede a precisão mínima que o caso de uso aguenta; precisão alta é GPS e GPS é bateria.
medium é usar a luz ambiente; pedir best é acender a lanterna.| Permissão | iOS | Android |
|---|---|---|
| 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 aproximada | iOS 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.
NSLocationWhenInUseUsageDescription no Info.plist o diálogo nem aparece. O texto é lido pelo utilizador e pela revisão da Apple: "Para mostrar a previsão da localidade mais próxima", não "Precisamos da sua localização".request() devolve logo negado, sem diálogo.openAppSettings). Nunca fiques em loop a pedir.Para os testes: uma app tem de tratar quatro casos: serviço desligado, negada, negada para sempre, concedida.
location, com notificação permanente visível. O Google Play exige um formulário de declaração, um vídeo da funcionalidade, e que seja central para a app. Senão, rejeição.UIBackgroundModes: location. Indicador azul na barra de estado. Periodicamente o iOS mostra "a app X usou a tua localização 34 vezes em segundo plano", com um mapa. Muita gente desinstala nesse momento.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.
geofence_service) ou código nativo por plataforma. Não entra no projeto.Para os testes: geofencing é feito pelo sistema operativo, poupa bateria, tem latência de minutos e limites de 100 (Android) e 20 (iOS).
| Opção | Custos e chaves | Plataformas | Notas |
|---|---|---|---|
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 iOS | Platform 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_map | Grátis, sem chave. Tiles públicos com política de uso justo e atribuição obrigatória. | Todas: é Dart puro | Para produção, fornecedor de tiles com chave (MapTiler, Stadia, Thunderforest). |
| Mapbox | Chave; grátis até um tecto mensal de carregamentos. | Android, iOS, web | Estilos personalizados, SDK nativo. |
flutter_map com tiles do OpenStreetMap. Sem chave, sem cartão, corre em qualquer portátil, sem platform view, funciona na web para a demo.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.
git clone https://github.com/pdm-daw-2026/pdm-aula-21.git
cd pdm-aula-21/starter
flutter pub get
flutter run
starter/ tem a app das aulas anteriores (lista, detalhe, favoritos) com TODOs numerados de 1 a 11 e testes a falhar em test/localizacao_test.dart. Os packages já estão no pubspec.yaml.solucao/ tem o estado final. Só se abre quando se está preso mais de 10 minutos.| Bloco | Horas | Passos |
|---|---|---|
| Permissões e posição | 09:20 a 10:15 | 1 a 7 |
| Intervalo | 10:15 a 10:30 | |
| Mapa | 10:30 a 11:30 | 8 a 10 |
| Integrar no projeto | 11:30 a 12:30 | 11 e exercícios |
No projeto do grupo, os mesmos packages entram com um comando:
flutter pub add geolocator permission_handler flutter_map latlong2 url_launcher
LatLng. url_launcher para o link da atribuição.minSdk 21 e compileSdk 34 ou superior em android/app/build.gradle. O template do Flutter atual já cumpre.platform :ios, '13.0' no Podfile, e uma macro para o permission_handler (passo 3).<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.
<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.
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,
};
}
}
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()),
));
}
switch é exaustivo: se acrescentares um estado ao enum, o compilador obriga-te a tratá-lo.context.mounted depois de cada await: o diálogo do sistema demora, e o utilizador pode ter navegado.initState.Geolocator.getLocationAccuracy() devolve.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
),
);
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';
Position traz latitude, longitude, accuracy (raio de erro em metros), timestamp e isMocked. Mostra a precisão ao utilizador quando for grande: "a cerca de 2 km".distanceBetween devolve metros em linha reta sobre a esfera (Haversine). Não é distância por estrada, e para os Açores chega.({Localidade localidade, double metros}) devolve dois valores sem criar uma classe. É Dart 3.timeLimit mais getLastKnownPosition: a app responde sempre em 10 s, com a melhor informação que houver.metros > 100 000, diz "fora dos Açores" e usa a localidade por defeito.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".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(),
],
);
}
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'))),
]);
FlutterMap é uma pilha de children: tiles em baixo, marcadores em cima, atribuição no canto. A ordem importa.urlTemplate com {z}/{x}/{y}: o mapa é feito de imagens de 256 px por nível de zoom. Trocar de fornecedor é trocar o URL (e acrescentar a chave).userAgentPackageName é obrigatório para os tiles públicos do OSM; sem ele, bloqueiam-te. A atribuição também é obrigatória, e o RichAttributionWidget resolve isso.cameraConstraint prende a câmara aos Açores: o utilizador não se perde no Atlântico. minZoom e maxZoom evitam pedir tiles inúteis.Marker é um widget qualquer: ícone, texto com a temperatura, um Card. O GestureDetector leva ao detalhe com a rota que já existe.iconePara é o helper das aulas anteriores que traduz TipoTempo em IconData; o starter já o tem.Column dentro do marcador.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)),
// 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(),
)
MapaEcra lê as localidades do repositório da aula 13 e passa-as ao MapaLocalidades. O mapa não sabe de HTTP.LocalizacaoNotifier guarda posicao, proximidade e erro, e tem o método atualizar() do passo 5. O ecrã inicial e o mapa observam-no.posicoes() e move o marcador do utilizador.| Sintoma | Causa provável | Solução |
|---|---|---|
| No iOS o diálogo de permissão nunca aparece | Falta a chave no Info.plist ou a macro PERMISSION_LOCATION no Podfile | Passo 3, depois pod install e rebuild completo |
| Mapa cinzento, sem tiles | Sem rede, sem INTERNET no manifesto (release), ou userAgentPackageName em falta | Confirma os três; vê o log por respostas 403 ou 429 do OSM |
| A posição aparece na Califórnia | Emulador com a localização por defeito da Google | Extended controls → Location, ou usa o telemóvel |
permanentlyDenied logo ao primeiro pedido | Já 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 sala | Precisão high exige GPS e não há céu | medium, timeLimit e getLastKnownPosition como no passo 6 |
/localidade/:id vão ser precisos.openAppSettings e a configuração por plataforma.TileLayer, marcadores, atribuição e fornecedores de tiles.