PDM · DAW 2

Aula 22 · Semana 10 · Segunda, 9 de novembro de 2026 · 16:00 às 18:00

Deep links e notificações locais

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ã.

Repo: pdm-aula-22

No fim desta aula consegues

Um URL abre um ecrã: os três tipos

TipoExemploSem a app instaladaQuem pode reclamar o link
Custom schemetempoacores://localidade/3Nada 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/3Abre o site no browser.Só quem prova o domínio com assetlinks.json.
Universal Links (iOS 9+)https://tempoacores.pt/localidade/3Abre o site no Safari.Só quem prova o domínio com apple-app-site-association.
https sem verificação (Android)O mesmo URL, sem autoVerifyBrowser.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.

Analogia Um deep link é uma morada completa, com rua, número, andar e porta, em vez de «vai à cidade e procura». Abrir a app pelo ícone é chegar à cidade; o URL leva à app e ao ecrã certo, com o id da localidade já no caminho.

Custom scheme vs https: segurança e fallback

Toque num link Custom scheme tempoacores://localidade/3 Ninguém verifica quem é o dono qualquer app regista tempoacores://; o SO pergunta Sem a app instalada, nada acontece não há fallback: o link morre num email ou QR App Link / Universal Link https://tempoacores.pt/localidade/3 O SO verifica o domínio assetlinks.json / AASA em /.well-known/ do site Com app abre a app; sem app abre o site o mesmo link serve em email, SMS, QR e anúncios
O custom scheme não é verificado por ninguém e não tem fallback; o link https prova a posse do domínio e cai no site sem app.

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.

Analogia 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.

Verificação de domínio: AASA e assetlinks.json

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.

Deferred deep links, atribuição e casos de uso

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.

Do URL ao ecrã: o router decide

App fechada arranque frio: URL = initialLocation ainda não há token nem cache App em segundo plano o SO entrega o link (onNewIntent) a app volta ao ecrã e navega Notificação tocada o payload traz a rota ex.: /localidade/3 GoRouter recebe o URL via MaterialApp.router igual a context.go(url) redirect: normaliza host e path valida o id (pode não existir) go(url) Ecrã de detalhe /localidade/:id carrega-se pelo id
Venha de onde vier, o URL passa pelo router como qualquer navegação; o que muda é se a app já estava a correr.

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.

Prática: clonar e correr

git clone https://github.com/pdm-daw-2026/pdm-aula-22.git
cd pdm-aula-22/starter
flutter pub get
flutter run
BlocoHorasPassos
Notificação de aviso16:40 a 17:101 a 4
Do toque ao ecrã17:10 a 17:305 e 6
Scheme e teste com adb17:30 a 17:457 e 8
Notificação agendada17:45 a 18:009

Passo 1: o que o Android precisa de saber

<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>

Passo 2: inicializar o plugin e criar o canal

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
  }
}

Passo 3: pedir permissão em contexto e mostrar

/// 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
    );

Notificações locais: o que interessa

Exercício 1 Completa os TODOs 1 a 3: 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.

Passo 4: detetar um aviso novo para um favorito

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);
    }
  }
}

Passo 5: do payload à rota, com a app a correr ou fechada

/// 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)
  ));
}

Passo 6: o router com rota inicial e URLs de fora

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
    );

Do toque ao ecrã: o que interessa

Exercício 2 Completa os TODOs 4 a 6: 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.

Passo 7: registar o scheme no Android e no iOS

<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.

Passo 8: testar com adb e xcrun

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
Exercício 3 Completa os TODOs 7 e 8: scheme no Android (e no iOS, se tiveres Mac), 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.

App Links e Universal Links: os ficheiros (visão)

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.

Passo 9: a previsão de amanhã às 20:00 com zonedSchedule

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));
}

Agendadas: o que interessa

Exercício 4 TODO 9: ao atualizar a previsão de um favorito, agenda "Amanhã em X" para as 20:00; interruptor "Previsão de amanhã" nas definições que cancela quando desligado. Testa com 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.

Até amanhã

Para fazer

  • Notificação de aviso e deep link no projeto: canal, permissão em contexto, tempoacores://localidade/:id a funcionar com a app fechada. PR revisto até quarta.
  • Mantém o atualizar() do notifier sem BuildContext: amanhã vai correr em segundo plano com o workmanager.
  • Quem tem Mac: scheme no Info.plist e teste com xcrun simctl openurl.

Para ler