Cosa sono le Live Activities

Le Live Activities sono quelle schede "vive" che su iOS compaiono nella lock screen e nella Dynamic Island: il countdown del rider che porta la pizza, il punteggio della partita, il timer della corsa, lo stato di un upload. Introdotte con iOS 16.1 tramite il framework ActivityKit, sono oggi uno degli strumenti più efficaci per tenere viva la presenza di un'app senza bombardare l'utente di notifiche push.

Il punto dolente per chi sviluppa in Flutter è che l'interfaccia di una Live Activity non può essere scritta in Flutter: deve essere una view SwiftUI dentro una Widget Extension, perché viene renderizzata dal sistema anche quando l'app non è in esecuzione. Quello che possiamo fare da Dart è avviare, aggiornare e terminare l'attività, passandole i dati.

In questa guida vediamo il percorso completo: setup Xcode, codice SwiftUI minimo, integrazione Dart con il pacchetto live_activities, aggiornamenti via push e, in chiusura, l'equivalente concettuale su Android.

Prerequisiti e limiti da conoscere prima di iniziare

Prima di scrivere una riga di codice, conviene mettere in chiaro i vincoli imposti da Apple, perché condizionano il design della funzionalità:

  • richiedono iOS 16.1+ (Dynamic Island solo su iPhone 14 Pro e successivi; sui modelli senza isola la Live Activity resta in lock screen);
  • il payload dello stato (ContentState) deve essere inferiore a 4 KB;
  • una Live Activity resta attiva per un massimo di 8 ore, dopodiché diventa stale e viene rimossa dalla lock screen entro altre 4 ore;
  • l'utente può disabilitarle per singola app: va sempre verificata la disponibilità prima di mostrare UI dedicate;
  • non possono essere avviate da background senza interazione (da iOS 17.2 è possibile avviarle via push, ma serve un'autorizzazione specifica);
  • la view SwiftUI non può usare animazioni arbitrarie, video o network: è un widget, quindi dati dentro, pixel fuori.

Passo 1: creare la Widget Extension in Xcode

Apri ios/Runner.xcworkspace e aggiungi un target Widget Extension (File > New > Target > Widget Extension), spuntando Include Live Activity. Chiamalo per esempio LiveActivityWidget. Xcode genererà lo scheletro SwiftUI.

Poi configura tre cose essenziali:

  1. Info.plist del target Runner: aggiungi la chiave NSSupportsLiveActivities impostata a YES (e NSSupportsLiveActivitiesFrequentUpdates a YES se prevedi molti aggiornamenti push).
  2. App Group: in Signing & Capabilities aggiungi la capability App Groups sia a Runner sia alla Widget Extension, con lo stesso identificativo (es. group.com.azienda.app). È il canale con cui Dart e SwiftUI si scambiano i dati.
  3. Deployment target: la extension deve puntare almeno a iOS 16.1.

Passo 2: definire attributi e view SwiftUI

Il pacchetto live_activities adotta un approccio generico: invece di definire uno ContentState tipizzato per ogni feature, scrive i dati in un UserDefaults condiviso dall'App Group e la view li rilegge per chiave. Il risultato è uno scheletro riutilizzabile:

import ActivityKit
import WidgetKit
import SwiftUI

struct LiveActivitiesAppAttributes: ActivityAttributes, Identifiable {
  public typealias LiveDeliveryData = ContentState
  public struct ContentState: Codable, Hashable { }
  var id = UUID()
}

let sharedDefault = UserDefaults(suiteName: "group.com.azienda.app")!

extension LiveActivitiesAppAttributes {
  func prefixedKey(_ key: String) -> String { "\(id)_\(key)" }
}

struct DeliveryWidget: Widget {
  var body: some WidgetConfiguration {
    ActivityConfiguration(for: LiveActivitiesAppAttributes.self) { context in
      // UI mostrata nella lock screen / banner
      let title = sharedDefault.string(forKey: context.attributes.prefixedKey("title")) ?? ""
      let eta = sharedDefault.integer(forKey: context.attributes.prefixedKey("eta"))

      VStack(alignment: .leading, spacing: 6) {
        Text(title).font(.headline)
        Text("Arrivo tra \(eta) minuti").font(.subheadline)
        ProgressView(value: Double(60 - eta), total: 60)
      }
      .padding()
      .activityBackgroundTint(Color.black.opacity(0.6))
    } dynamicIsland: { context in
      let eta = sharedDefault.integer(forKey: context.attributes.prefixedKey("eta"))
      return DynamicIsland {
        DynamicIslandExpandedRegion(.leading) { Image(systemName: "bicycle") }
        DynamicIslandExpandedRegion(.trailing) { Text("\(eta) min") }
        DynamicIslandExpandedRegion(.bottom) { Text("Il tuo ordine è in consegna") }
      } compactLeading: {
        Image(systemName: "bicycle")
      } compactTrailing: {
        Text("\(eta)m")
      } minimal: {
        Image(systemName: "bicycle")
      }
    }
  }
}

Nota le tre modalità della Dynamic Island: compact (app singola attiva), minimal (due attività contemporanee) ed expanded (long press). Vanno progettate tutte e tre, altrimenti l'esperienza risulta incompleta.

Passo 3: pilotare l'attività da Dart

Aggiungi la dipendenza:

dependencies:
  live_activities: ^2.3.0

E incapsula l'SDK in un servizio, così il resto dell'app non dipende dal pacchetto:

import 'package:live_activities/live_activities.dart';

class DeliveryActivityService {
  DeliveryActivityService(this._plugin);

  final LiveActivities _plugin;
  String? _activityId;

  Future<void> init() async {
    await _plugin.init(appGroupId: 'group.com.azienda.app');
  }

  Future<bool> get isAvailable async => _plugin.areActivitiesEnabled();

  Future<void> start({required String title, required int eta}) async {
    if (!await isAvailable) return;
    _activityId = await _plugin.createActivity(
      {
        'title': title,
        'eta': eta,
      },
      removeWhenAppIsKilled: true,
    );
  }

  Future<void> update(int eta) async {
    final id = _activityId;
    if (id == null) return;
    await _plugin.updateActivity(id, {'eta': eta});
  }

  Future<void> end() async {
    final id = _activityId;
    if (id == null) return;
    await _plugin.endActivity(id);
    _activityId = null;
  }
}

Punti di attenzione:

  • createActivity va chiamato con l'app in foreground. Se l'utente avvia un ordine e poi mette l'app in background, la creazione fallisce silenziosamente.
  • Mantieni l'activityId in storage persistente (shared_preferences) oppure recuperalo con getAllActivitiesIds() al riavvio: senza di esso non puoi aggiornare né terminare un'attività creata in una sessione precedente.
  • Le chiavi usate in Dart devono corrispondere esattamente a quelle lette in SwiftUI.

Chiudere sempre il cerchio

Un errore frequente è lasciare Live Activity orfane sulla lock screen. Al riavvio dell'app conviene fare pulizia:

Future<void> cleanupStaleActivities() async {
  final ids = await _plugin.getAllActivitiesIds();
  for (final id in ids) {
    // es. se l'ordine è già concluso lato backend
    await _plugin.endActivity(id);
  }
}

Passo 4: aggiornare da remoto con ActivityKit push

Il caso d'uso reale (consegna, partita, treno in ritardo) richiede aggiornamenti quando l'app non gira. ActivityKit assegna a ogni attività un push token dedicato, diverso dal token APNs dell'app. Il flusso è:

  1. l'app crea la Live Activity;
  2. riceve il push token dell'attività;
  3. lo invia al backend insieme all'ID dell'ordine;
  4. il backend invia notifiche APNs di tipo liveactivity al token specifico.

In Dart il token arriva da uno stream:

_plugin.activityUpdateStream.listen((update) {
  update.map(
    active: (state) => api.registerActivityToken(
      activityId: state.activityId,
      token: state.activityToken,
    ),
    ended: (state) => api.unregisterActivity(state.activityId),
    stale: (state) => api.unregisterActivity(state.activityId),
    unknown: (_) {},
  );
});

Il payload APNs lato server ha questa forma (header apns-push-type: liveactivity, apns-topic: <bundle-id>.push-type.liveactivity):

{
  "aps": {
    "timestamp": 1735689600,
    "event": "update",
    "content-state": { "eta": 12, "title": "Il rider è in zona" },
    "alert": { "title": "Ordine in arrivo", "body": "12 minuti" }
  }
}

Con event: "end" e il campo dismissal-date si chiude l'attività da remoto. Se servono aggiornamenti molto frequenti (più di qualche decina all'ora) va richiesto l'entitlement per gli frequent updates: il sistema applica comunque un budget e può scartare i push in eccesso, quindi progetta assumendo che qualche aggiornamento andrà perso e invia sempre lo stato completo, mai delta.

E su Android?

Android non ha ActivityKit, ma l'equivalente funzionale è la notifica persistente di progresso. Con flutter_local_notifications si ottiene un risultato simile:

await notifications.show(
  1,
  'Ordine in consegna',
  'Arrivo tra 12 minuti',
  const NotificationDetails(
    android: AndroidNotificationDetails(
      'delivery',
      'Consegne',
      ongoing: true,
      onlyAlertOnce: true,
      showProgress: true,
      maxProgress: 60,
      progress: 48,
      category: AndroidNotificationCategory.progress,
    ),
  ),
);

Da Android 16 esiste inoltre il concetto di Live Updates (notifiche promosse con ProgressStyle, visibili anche in forma compatta nella status bar), che avvicina molto l'esperienza a quella iOS. L'approccio corretto in Flutter è quindi un'astrazione comune (OrderProgressPresenter) con due implementazioni dietro: Live Activity su iOS, notifica ongoing su Android.

Checklist finale

  • Verifica sempre areActivitiesEnabled() prima di promettere la funzionalità all'utente.
  • Mantieni il ContentState piccolo e serializzabile: niente immagini, solo stringhe e numeri (le immagini vanno messe negli asset della extension o scritte nell'App Group).
  • Usa Text(timerInterval:) di SwiftUI per i countdown: si aggiorna da solo senza consumare budget di push.
  • Testa su dispositivo fisico: il simulatore supporta le Live Activity, ma non riproduce fedelmente il comportamento dei push e della Dynamic Island.
  • Chiudi l'attività appena l'evento finisce: una scheda ferma a "in consegna" da tre ore è peggio di nessuna scheda.

Le Live Activities non sono una scorciatoia grafica ma una feature di prodotto: funzionano bene solo per eventi con inizio, progresso e fine ben definiti. Quando ci sono, però, sono il modo più elegante per restare sullo schermo dell'utente senza invaderlo.