Aula 22 · Semana 10 · Segunda, 9 de novembro de 2026 · 16:00 às 18:00
Teoria: deep links: custom schemes, App Links e Universal Links, verificação de domínio e deferred deep links. Prática: notificação local de aviso para um favorito, abertura do detalhe a partir da notificação, esquema tempoacores:// testado com adb, e notificação agendada com a previsão de amanhã.
assetlinks.json e apple-app-site-association, e o que acontece quando falha.flutter_local_notifications.go_router a partir do payload de uma notificação ou de um URL, com a app a correr ou fechada.zonedSchedule e fuso horário.| Tipo | Exemplo | Sem a app instalada | Quem pode reclamar o link |
|---|---|---|---|
| Custom scheme | tempoacores://localidade/3 | Nada acontece (Android) ou "não é possível abrir" (iOS). Link morto. | Qualquer app. Duas apps com o mesmo scheme: conflito sem regra. |
| App Links (Android 6+) | https://tempoacores.pt/localidade/3 | Abre o site no browser. | Só quem prova o domínio com assetlinks.json. |
| Universal Links (iOS 9+) | https://tempoacores.pt/localidade/3 | Abre o site no Safari. | Só quem prova o domínio com apple-app-site-association. |
| https sem verificação (Android) | O mesmo URL, sem autoVerify | Browser. | O Android pergunta "abrir com…" e o utilizador escolhe. |
Um deep link é um URL que abre a app num ecrã específico em vez do ecrã inicial. Os três tipos fazem isso; diferem em quem os pode registar e no que acontece sem a app.
Para os testes: App Links e Universal Links são URLs https normais que o sistema entrega à app depois de verificar o domínio; um custom scheme é um URL só da app, sem verificação.
id da localidade já no caminho.tempoacores://. Com duas apps a reclamar o mesmo scheme, o Android pergunta "abrir com" e o iOS escolhe uma sem regra documentada. É por isso que nenhum banco manda tokens por custom scheme.https cai no site: o mesmo link serve num email, num SMS, num QR ou num anúncio, com ou sem app instalada./.well-known/, e no iOS o entitlement Associated Domains, que exige conta de developer paga.com.googleusercontent.apps.123:/oauth2redirect), porque o PKCE da aula 17 protege o código mesmo que outra app o apanhe.spotify:track:… e https://open.spotify.com/track/…. O botão de partilha usa sempre o segundo.Para os testes: um custom scheme não é verificado por ninguém e não tem fallback; um App Link ou Universal Link prova a posse do domínio e cai no site quando a app não está instalada.
tempoacores:// é uma alcunha: qualquer app a pode reclamar e ninguém verifica quem responde. https://tempoacores.pt/... é uma morada registada: o sistema confirma no domínio, através do assetlinks.json ou do AASA, que a app é mesmo a dona da morada; e se a app não estiver instalada, a carta chega na mesma, ao site.https://tempoacores.pt/.well-known/apple-app-site-association, JSON sem extensão, servido como application/json por HTTPS sem redirects. Lista appIDs (Team ID + bundle id) e os caminhos. A app declara applinks:tempoacores.pt no entitlement.?mode=developer no entitlement.https://tempoacores.pt/.well-known/assetlinks.json com package_name e o SHA-256 do certificado de assinatura. O intent-filter leva android:autoVerify="true". Verificado na instalação../gradlew signingReport mostra-as.adb shell pm get-app-links pt.uac.tempoacores; no iOS, Definições → Developer → Universal Links.Para os testes: a verificação é feita pelo sistema na instalação, contra um ficheiro em /.well-known/ do domínio; a falha é silenciosa e o link cai no browser.
Para os testes: um deferred deep link sobrevive à instalação e precisa de um serviço externo, porque as lojas não entregam o link à app.
Intent com ACTION_VIEW, em onCreate se estava fechada ou onNewIntent se estava a correr. iOS: application(_:open:), ou continue userActivity para Universal Links.RouteInformation e o MaterialApp.router entrega-o ao go_router. Para o router é igual a context.go(url). As rotas desenhadas como URLs públicos na aula 9 pagam-se aqui.id, sem depender de o utilizador ter passado pela lista. E valida: o id vem de fora e pode não existir.adb shell am start -a android.intent.action.VIEW -d "tempoacores://localidade/3" no Android; xcrun simctl openurl booted "tempoacores://localidade/3" no simulador iOS.Para os testes: um deep link é tratado pelo router como qualquer navegação; o que muda é de onde vem o URL e se a app já estava a correr.
git clone https://github.com/pdm-daw-2026/pdm-aula-22.git
cd pdm-aula-22/starter
flutter pub get
flutter run
starter/ tem a app das aulas anteriores (lista, detalhe, favoritos, avisos, mapa) com TODOs numerados de 1 a 9 e testes a falhar em test/notificacoes_test.dart e test/rotas_test.dart. Os packages flutter_local_notifications, timezone e go_router já estão no pubspec.yaml.solucao/ tem o estado final. Só se abre quando se está preso mais de 10 minutos.xcrun simctl openurl.| Bloco | Horas | Passos |
|---|---|---|
| Notificação de aviso | 16:40 a 17:10 | 1 a 4 |
| Do toque ao ecrã | 17:10 a 17:30 | 5 e 6 |
| Scheme e teste com adb | 17:30 a 17:45 | 7 e 8 |
| Notificação agendada | 17:45 a 18:00 | 9 |
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Android 13+: mostrar notificações é uma permissão de runtime -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Agendadas sobrevivem a um reinício do telemóvel -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<application android:label="Tempo Açores" android:icon="@mipmap/ic_launcher">
<activity android:name=".MainActivity" ...>...</activity>
<!-- Receivers do plugin: disparar a agendada e re-agendar depois do boot -->
<receiver android:exported="false"
android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver" />
<receiver android:exported="false"
android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
</application>
</manifest>
flutter pub add flutter_local_notifications timezone, e em android/app/build.gradle.kts o plugin exige isCoreLibraryDesugaringEnabled = true mais a dependência coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4"). O starter já tem tudo.import 'package:flutter_local_notifications/flutter_local_notifications.dart';
class ServicoNotificacoes {
final _plugin = FlutterLocalNotificationsPlugin();
static const canalAvisos = AndroidNotificationChannel(
'avisos', 'Avisos meteorológicos',
description: 'Avisos do IPMA para as tuas localidades favoritas',
importance: Importance.high, // aparece como heads-up, com som
);
Future<void> inicializar({required void Function(String rota) aoTocar}) async {
await _plugin.initialize(
const InitializationSettings(
android: AndroidInitializationSettings('@mipmap/ic_launcher'),
iOS: DarwinInitializationSettings( // não pedir já: pedimos em contexto
requestAlertPermission: false, requestBadgePermission: false, requestSoundPermission: false),
),
onDidReceiveNotificationResponse: (resposta) {
if (resposta.payload case final rota?) aoTocar(rota); // toque com a app a correr
},
);
await _plugin.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
?.createNotificationChannel(canalAvisos); // uma vez; o utilizador gere-o no sistema
}
}
/// Pede quando o utilizador liga "Avisar-me" nas definições, nunca no arranque.
Future<bool> pedirPermissao() async {
final android = _plugin.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>();
if (android != null) {
return await android.requestNotificationsPermission() ?? false; // 13+; antes, true
}
final ios = _plugin.resolvePlatformSpecificImplementation<
IOSFlutterLocalNotificationsPlugin>();
return await ios?.requestPermissions(alert: true, badge: true, sound: true) ?? false;
}
Future<void> mostrarAviso(Aviso a) => _plugin.show(
a.id, // id estável: o mesmo aviso substitui a notificação, não a duplica
'Aviso ${a.nivel}: ${a.localidadeNome}',
a.descricao,
NotificationDetails(
android: AndroidNotificationDetails(canalAvisos.id, canalAvisos.name,
channelDescription: canalAvisos.description,
importance: Importance.high, priority: Priority.high),
iOS: const DarwinNotificationDetails(),
),
payload: '/localidade/${a.localidadeId}', // a rota que o router abre no toque
);
id, título, corpo, detalhes por plataforma e payload. O id é a identidade: show com o mesmo id substitui. Usa o id do aviso, não um contador.Importance.high dá heads-up. Mudar a importância depois de criado não tem efeito: o canal passa a ser do utilizador.main. Android 13+ é runtime; antes é automática. O iOS pergunta uma vez e nunca mais: a segunda tentativa vai às definições.DarwinNotificationDetails./localidade/3. Quem abre o ecrã vai buscar o resto ao repositório.res/drawable. O ic_launcher fica um quadrado branco em release.ServicoNotificacoes com inicializar, pedirPermissao e mostrarAviso; interruptor "Avisar-me de avisos" nas definições que pede a permissão e mostra o resultado; botão de teste na lista de avisos que chama mostrarAviso com o primeiro aviso. Nega a permissão e vê o que acontece. O teste notificacoes_test.dart verifica o payload gerado.class AvisosNotifier extends ChangeNotifier {
AvisosNotifier(this._repositorio, this._favoritos, this._notificacoes);
final AvisosRepository _repositorio; // GET /v1/warnings (aulas 14 e 16)
final FavoritosNotifier _favoritos; // aula 17
final ServicoNotificacoes _notificacoes;
List<Aviso> _avisos = [];
bool _jaCarregou = false;
List<Aviso> get avisos => _avisos;
Future<void> atualizar() async {
final conhecidos = _avisos.map((a) => a.id).toSet();
_avisos = await _repositorio.obterAvisos();
notifyListeners();
final primeiraVez = !_jaCarregou;
_jaCarregou = true;
if (primeiraVez) return; // ao arrancar, nada é "novo"
for (final a in _avisos) {
final novo = !conhecidos.contains(a.id) && _favoritos.contem(a.localidadeId);
if (novo) await _notificacoes.mostrarAviso(a);
}
}
}
/// null se a app abriu normalmente; a rota do payload se foi um toque que a lançou.
Future<String?> rotaDeArranque() async {
final detalhes = await _plugin.getNotificationAppLaunchDetails();
if (detalhes?.didNotificationLaunchApp != true) return null;
return detalhes!.notificationResponse?.payload;
}
// lib/main.dart
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); // plugins antes de runApp
final notificacoes = ServicoNotificacoes();
// App a correr (à frente ou atrás): o toque chega ao callback e o router navega.
await notificacoes.inicializar(aoTocar: (rota) => router.go(rota));
// App fechada: o callback não dispara; a rota vai como rota inicial.
final rotaInicial = await notificacoes.rotaDeArranque();
router = criarRouter(rotaInicial: rotaInicial ?? '/');
runApp(MultiProvider(
providers: [
Provider.value(value: notificacoes),
// ...ApiClient, repositórios e notifiers das aulas anteriores
],
child: const TempoApp(), // MaterialApp.router(routerConfig: router)
));
}
late final GoRouter router; // criado em main(), depois de saber a rota inicial
GoRouter criarRouter({String rotaInicial = '/'}) => GoRouter(
initialLocation: rotaInicial,
redirect: (context, state) {
final uri = state.uri;
// tempoacores://localidade/3: "localidade" é o host e "/3" o path, e o
// go_router só compara o path. Normalizamos para /localidade/3.
if (uri.scheme == 'tempoacores') return '/${uri.host}${uri.path}';
return null; // https://tempoacores.pt/localidade/3 já traz o path certo
},
routes: [
GoRoute(
path: '/',
builder: (context, state) => const EcraLocalidades(),
routes: [
GoRoute(
path: 'localidade/:id', // o id vem de fora: valida
builder: (context, state) =>
EcraDetalhe(id: int.tryParse(state.pathParameters['id'] ?? '')), // null → "não encontrada"
),
],
),
], // ...mais avisos, mapa e definições no StatefulShellRoute das aulas 9 e 21
);
onDidReceiveNotificationResponse → router.go. Fechada: getNotificationAppLaunchDetails → initialLocation. Quem só trata o primeiro vê o toque com a app fechada abrir a lista.WidgetsFlutterBinding.ensureInitialized() antes de tocar em plugins no main. Sem isto, MissingPluginException.redirect normaliza URLs que vêm de fora antes de o router procurar rotas: scheme, host, prefixos. Um único sítio para todos os formatos.id vem de fora: int.tryParse e um ecrã "localidade não encontrada" com botão para a lista. Nunca !.conhecidos antes de atualizar e comparar depois; a primeira carga não notifica. Extra: persistir os ids vistos em shared_preferences para sobreviver a reinícios. O workmanager de amanhã vai precisar disso.AvisosRepositoryFake que devolve um aviso a mais na segunda chamada.atualizar() notifica só avisos novos de localidades favoritas; o toque com a app em segundo plano abre o detalhe; o toque com a app fechada (para a app no IDE, toca na notificação) também. O teste rotas_test.dart verifica que criarRouter(rotaInicial: '/localidade/3') mostra EcraDetalhe.<activity android:name=".MainActivity" android:exported="true" android:launchMode="singleTop" ...>
<!-- Ligado por defeito desde o Flutter 3.27; explícito não faz mal -->
<meta-data android:name="flutter_deeplinking_enabled" android:value="true" />
<!-- tempoacores://localidade/3 -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="tempoacores" />
</intent-filter>
</activity>
<key>FlutterDeepLinkingEnabled</key>
<true/>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key><string>Editor</string>
<key>CFBundleURLName</key><string>pt.uac.tempoacores</string>
<key>CFBundleURLSchemes</key>
<array><string>tempoacores</string></array>
</dict>
</array>
singleTop (já no template) faz a app a correr receber o URL em onNewIntent em vez de abrir uma segunda instância. BROWSABLE deixa um link numa página web abrir a app. Universal Links no iOS não vão no Info.plist: vão em Runner.entitlements, via Xcode → Signing & Capabilities → Associated Domains.
adb shell am start -a android.intent.action.VIEW -d "tempoacores://localidade/3" pt.uac.tempoacores
xcrun simctl openurl booted "tempoacores://localidade/3"
adb shell pm get-app-links pt.uac.tempoacores
-d é o data do Intent; o último argumento restringe ao nosso package. Sem ele, o Android pergunta "abrir com" se outra app tiver o mesmo scheme.redirect no router, e o comando adb nos três cenários. tempoacores://localidade/999 mostra "localidade não encontrada" com botão para a lista. Extra: tempoacores://avisos abre o separador de avisos.O backend do curso serve um exemplo em /.well-known/assetlinks.json. Para o vosso projeto seria preciso o SHA-256 da vossa chave (./gradlew signingReport) e um domínio próprio, por isso fica como visão.
[{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "pt.uac.tempoacores",
"sha256_cert_fingerprints": ["14:6D:E9:83:C5:73:06:50:D8:EE:B9:95:2F:34:FC:64:16:A0:83:42:E6:1D:BE:A8:8A:04:96:B2:3F:CF:44:E5"]
}
}]
{
"applinks": {
"details": [
{ "appIDs": ["ABCDE12345.pt.uac.tempoacores"], "components": [{ "/": "/localidade/*" }] }
]
}
}
Do lado da app: um segundo intent-filter com android:autoVerify="true", android:scheme="https" e android:host="tempoacores.pt"; no iOS, o entitlement applinks:tempoacores.pt. O redirect do passo 6 não precisa de mudar: o path já vem certo.
import 'package:timezone/timezone.dart' as tz; // tz.local definido em inicializar()
Future<void> agendarPrevisaoDeAmanha(Localidade l, Previsao amanha) =>
_plugin.zonedSchedule(
1000 + l.id, // um id por localidade: re-agendar substitui, não acumula
'Amanhã em ${l.nome}',
'${amanha.tMin.round()}° a ${amanha.tMax.round()}°, ${amanha.tipo.descricao}',
_proximas20h(),
const NotificationDetails(
android: AndroidNotificationDetails('previsao', 'Previsão de amanhã',
channelDescription: 'Todos os dias às 20:00, para os favoritos'),
iOS: DarwinNotificationDetails(),
),
androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle, // sem alarme exato
payload: '/localidade/${l.id}',
);
/// Hoje às 20:00 se ainda não passou; senão amanhã às 20:00. Sempre em tz.local.
tz.TZDateTime _proximas20h() {
final agora = tz.TZDateTime.now(tz.local);
final hoje = tz.TZDateTime(tz.local, agora.year, agora.month, agora.day, 20);
return hoje.isAfter(agora) ? hoje : hoje.add(const Duration(days: 1));
}
zonedSchedule recebe um TZDateTime, não um DateTime: a hora pertence a um fuso. Em inicializar(): tz.initializeTimeZones() e tz.setLocalLocation(tz.getLocation('Atlantic/Azores')). Sem isto tz.local é UTC e as 20:00 tocam às 19:00 no inverno. Para o fuso do telemóvel, flutter_timezone.inexactAllowWhileIdle pode atrasar minutos (Doze, amanhã). exactAllowWhileIdle exige SCHEDULE_EXACT_ALARM no Android 12+ e no 14 só despertadores e calendários a têm por defeito. Para uma previsão, inexato chega.RECEIVE_BOOT_COMPLETED e o receiver do passo 1, um reinício apaga o agendamento. iOS: guarda as 64 pendentes mais próximas e descarta o resto.pendingNotificationRequests() lista o que está agendado; cancel(id) e cancelAll(). Para testar, agenda para agora + 10 s e volta depois às 20:00.cancel de todas. Canal desligado no sistema: não toca e não há erro; a app não fica a saber.agora + 10 s. Extra: o toque abre o detalhe já no dia de amanhã, com o payload /localidade/3?dia=1 lido em state.uri.queryParameters.tempoacores://localidade/:id a funcionar com a app fechada. PR revisto até quarta.atualizar() do notifier sem BuildContext: amanhã vai correr em segundo plano com o workmanager.Info.plist e teste com xcrun simctl openurl.assetlinks.json, o autoVerify e como diagnosticar com adb.