Skip to main content

app_alerts 0.1.0 copy "app_alerts: ^0.1.0" to clipboard
app_alerts: ^0.1.0 copied to clipboard

App-wide, prioritized alert delivery for Flutter. Polls (or receives pushed) alerts from a configurable backend source, tracks what has already been presented, and shows urgent pop-up alerts above eve [...]

app_alerts #

CI

App-wide, prioritized alert delivery for Flutter.

app_alerts connects your app to a backend alert feed and takes care of everything between "the backend published an alert" and "the user saw it, exactly once":

  • Configurable source — poll a JSON document over HTTP(S) (e.g. a static file on NetStorage/a CDN), receive pushes over a WebSocket, or plug in any custom transport.
  • App-wide presentation — initialize once, add one line to your MaterialApp, and urgent alerts pop up above every route, dialog, and sheet, no matter what the app is doing.
  • Two alert styles — urgent (a modal pop-up with a short lock-out before tap-outside dismissal) and inline (raw content handed to your app to render in its own visual style).
  • Presented-alert tracking — each alert is shown at most once, with pluggable persistence.
  • Backend-defined priority — when several alerts are pending, they are presented in the order the backend asked for.
  • Every platform, including embedded — no plugins, no platform channels, no Material dependency in the overlay. Pure Dart transport (http, web_socket_channel) and widget-layer-only UI mean it runs anywhere Flutter runs: Android, iOS, web, Windows, macOS, Linux, and embedded targets such as flutter-elinux.
  • Fully accessible — screen-reader announcements, semantic modal routes, keyboard operation, focus containment, and 48 px touch targets.

Quick start #

import 'package:app_alerts/app_alerts.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  await AppAlerts.initialize(
    const AppAlertsConfig(
      useSampleData: true, // Try it out with the bundled sample feed.
      pollInterval: Duration(seconds: 60),
    ),
  );

  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      builder: AppAlerts.builder, // Urgent alerts above every route.
      home: HomePage(),
    );
  }
}

That is the whole integration for urgent alerts. When you have a real backend, swap the sample flag for a source:

await AppAlerts.initialize(
  AppAlertsConfig(
    source: HttpAlertSource(
      Uri.parse('https://netstorage.example.com/alerts/feed.json'),
    ),
    pollInterval: const Duration(seconds: 60),
  ),
);

If your app already uses MaterialApp.builder, compose manually:

builder: (context, child) => MyOtherWrapper(
  child: AlertsOverlay(
    controller: AppAlerts.instance,
    child: child!,
  ),
),

The JSON contract #

The backend publishes a single JSON document (for example a static file on NetStorage that an operator updates):

{
  "version": 1,
  "alerts": [
    {
      "id": "2026-07-outage-42",
      "type": "urgent",
      "priority": 1,
      "title": "Service disruption",
      "message": "Logins are temporarily unavailable. We are on it.",
      "okLabel": "Not now",
      "url": "https://status.example.com/incident-42",
      "goLabel": "View status",
      "createdAt": "2026-07-20T14:00:00Z",
      "expiresAt": "2026-07-21T00:00:00Z",
      "metadata": { "category": "incident" }
    },
    {
      "id": "2026-07-feature-darkmode",
      "type": "inline",
      "priority": 5,
      "title": "New: dark mode",
      "message": "Switch themes from Settings > Appearance.",
      "createdAt": "2026-07-18T09:30:00Z"
    }
  ]
}
Field Required Meaning
id yes Globally unique, stable. Deduplication keys on it: re-publishing the same id never re-prompts a user who has seen it. Use a new id to deliberately re-prompt.
title yes Headline.
type no "urgent" or "inline". Unknown or missing values fall back to "inline", so a newer backend never escalates an alert an older client doesn't understand into a blocking pop-up.
priority no Integer; lower = more important (1 outranks 5). Ties break by createdAt, oldest first. Missing = 1000.
message no Body text.
okLabel no Label for the urgent pop-up's acknowledge button (default "OK"; default "Dismiss" when url is present).
url no A URI to open when the user acts on the alert — a web URL (https://…) or an app deep link (myapp://…). Routed through your onOpenUrl handler; see Alert URLs. Values without a scheme are treated as absent.
goLabel no Label for the urgent pop-up's Go button (default "Go"). Only meaningful with url.
createdAt no ISO-8601. Priority tie-breaker.
expiresAt no ISO-8601. Expired alerts are silently dropped, even if already queued.
metadata no Free-form object passed through untouched to your app.

The document is always the complete current feed, never a delta — that makes polling and push interchangeable, and it means removing an alert from the file revokes it from clients that haven't shown it yet. Individual malformed rows are skipped (and counted) rather than failing the feed.

The bundled sample is exported as kSampleAlertFeedJson — copy it as the starting point for your real document.

Urgent alerts #

The head of the priority queue is presented as a modal pop-up above the whole app, one at a time:

  • For the first ~2 seconds (configurable via AppAlertsConfig.urgentDismissLockDuration) tapping outside the dialog and pressing Esc do nothing, so an accidental tap can't dismiss an alert the user never read.
  • After the lock elapses, tapping outside (or Esc) dismisses.
  • The OK button dismisses at any time, including during the lock.
  • Dismissing records the alert as presented and immediately shows the next pending urgent alert, with a fresh lock.
  • If the alert carries a url (and you configured onOpenUrl), the single OK becomes a pair: a primary Go button (goLabel) that routes the URL through your handler and then dismisses, and a secondary Dismiss button (okLabel) that just dismisses. Both work during the lock.

Custom urgent UI #

The built-in pop-up is deliberately unbranded (and follows the platform light/dark brightness, since it cannot depend on your app's theme). To match your design system, replace it wholesale:

AppAlertsConfig(
  source: HttpAlertSource(feedUrl),
  urgentAlertBuilder: (context, alert, actions) => MyBrandedModal(
    title: alert.title,
    body: alert.message,
    onDismiss: actions.dismiss,           // Records "presented".
    onGo: actions.open,                    // Null when no URL/handler.
  ),
)

The package keeps deciding when and which alert shows, keys the subtree per alert id (fresh state per alert), and keeps the app behind the alert excluded from focus and semantics. Your builder owns everything visual — including the barrier and, if you want it, the dismiss-lock behavior of the built-in view it replaces. Both callbacks are id-scoped: firing them after the alert has changed is a harmless no-op.

Alerts can point at something: a status page, a store listing, an in-app screen. The package never launches URLs itself (that would require a platform plugin and break embedded targets); instead you supply the handler once, at initialize time — which is exactly where deep-link interception happens:

await AppAlerts.initialize(
  AppAlertsConfig(
    source: HttpAlertSource(feedUrl),
    onOpenUrl: (url, alert) {
      if (url.scheme == 'myapp') {
        router.go(url.path);      // Intercept as an in-app deep link.
      } else {
        launchUrl(url);           // url_launcher → external browser.
      }
    },
  ),
);
  • Urgent alerts get the Go/Dismiss button pair automatically. Go invokes your handler, then dismisses and records the alert as presented; Dismiss never touches the handler. Without an onOpenUrl handler, URL alerts fall back to a single OK — a Go button that goes nowhere is never shown.
  • Inline alerts receive the URL as alert.url for your own UI to use however it likes — show it, hide it, or route it through the same app-wide handler with AppAlerts.instance.openUrl(alert).
  • A handler that throws lands on lastError instead of propagating; a bad deep link never breaks alert delivery.

Inline alerts #

Inline alerts never pop up. Your app pulls their raw content and renders it however it likes:

InlineAlertsBuilder(
  builder: (context, alerts, acknowledge) {
    if (alerts.isEmpty) return const SizedBox.shrink();
    final alert = alerts.first; // Highest priority first.
    return MyBanner(
      title: alert.title,
      body: alert.message,
      onClose: () => acknowledge(alert.id), // Never shown again.
    );
  },
)

Or listen directly: AppAlerts.instance.inlineAlerts is a ValueListenable<List<Alert>>, and acknowledgeInline(id) records presentation. Call acknowledge only when the user has genuinely seen the alert — that is the moment it is written to history.

Push instead of polling #

Any source may expose a push stream; the built-in WebSocket source pairs one with an HTTP fetch for cold starts:

AppAlertsConfig(
  source: WebSocketAlertSource(
    Uri.parse('wss://alerts.example.com/stream'),
    initialFetch: HttpAlertSource(feedUrl), // Optional cold-start sync.
  ),
  pollInterval: null, // Push-only; or keep polling as a belt-and-braces.
)

The server sends the same feed document as a text message whenever the alert set changes. The connection reconnects automatically with exponential backoff. For SSE or any other push transport, implement AlertSource and return your own pushStream.

Persistent "already presented" history #

By default history is in memory: alerts re-appear after a cold start. To persist it, bridge to whatever storage your app already uses — the package deliberately takes no storage-plugin dependency so it stays runnable on every platform:

final prefs = await SharedPreferences.getInstance();

await AppAlerts.initialize(
  AppAlertsConfig(
    source: HttpAlertSource(feedUrl),
    historyStore: DelegatingAlertHistoryStore(
      load: () async =>
          (prefs.getStringList('presented_alerts') ?? const []).toSet(),
      save: (ids) => prefs.setStringList('presented_alerts', ids.toList()),
    ),
  ),
);

Or implement AlertHistoryStore directly for files, databases, or server-side tracking.

Accessibility #

  • New urgent alerts are announced assertively to screen readers (VoiceOver/TalkBack/NVDA…).
  • The pop-up scopes and names a semantic route; while it is up, the app behind it is excluded from both screen-reader traversal and keyboard focus, matching platform modal behavior.
  • The OK button is autofocused, activatable with Enter/Space, exposes proper button semantics, and meets the 48×48 minimum touch target.
  • The barrier advertises semantic dismissal only once it actually becomes dismissible; Esc mirrors the tap-outside rules.
  • The dialog respects the platform light/dark brightness and uses WCAG-AA-contrast colors.

Architecture #

┌─────────────────────────────────────────────────────────┐
│ Widget layer (flutter/widgets only — no Material dep)   │
│   AppAlerts.builder → AlertsOverlay → UrgentAlertView│
│   InlineAlertsBuilder                                    │
├─────────────────────────────────────────────────────────┤
│ Core (pure Dart, fully unit-testable)                    │
│   AlertsController — poll/push merge, dedupe, priority   │
│   AlertSource — Http / WebSocket / Sample / custom       │
│   AlertHistoryStore — InMemory / Delegating / custom     │
│   Alert, AlertFeed — the JSON contract                   │
└─────────────────────────────────────────────────────────┘

Key decisions:

  • Feeds are state, not events. Every fetch or push carries the complete current alert set, and the controller rebuilds pending state from it. This makes ingest idempotent, lets the backend revoke or edit pending alerts, and keeps push and poll interchangeable.
  • The alert on screen is immutable. A fetch never restates, replaces, or revokes the urgent alert the user is currently reading.
  • History records what the user saw, not what the client fetched. Ids go into the store at dismissal/acknowledgement time, and a failing store never blocks dismissal (worst case: the alert re-appears next session).
  • No plugins. Everything platform-specific is behind small interfaces (AlertSource, AlertHistoryStore) that the host app can implement, keeping the package itself runnable on all six standard platforms plus embedded.

Example #

A complete runnable app lives in example/: sample feed, app-wide urgent pop-ups, and inline alerts rendered as Material cards.

cd example && flutter run

Testing your integration #

SampleAlertSource, InMemoryAlertHistoryStore(seed: ...), and AlertsController (with pollInterval: null and an injectable clock) are all designed to be driven directly from widget tests — see this package's own test/ directory for patterns, including barrier-lock timing tests and semantics assertions.

Releasing #

Continuous integration runs formatting, analysis, and the full test suite on every push and pull request (ci.yaml). Releases to pub.dev are automated via a version tag (publish.yaml) using pub.dev's OIDC publishing — no API token is stored in the repo.

One-time setup:

  1. Publish 0.1.0 manually, once, from a machine with pub.dev access (automated publishing can only be configured on a package that already exists):

    dart pub login       # first time only
    flutter pub publish
    
  2. On the package's pub.dev page, open Admin → Automated publishing, enable GitHub Actions, set the repository to tomquercia/app_alerts, and the tag pattern to v{{version}}.

Every release after that:

  1. Bump version: in pubspec.yaml and add a CHANGELOG.md entry.

  2. Commit, then tag and push:

    git tag v0.1.1
    git push origin v0.1.1
    

The tag triggers the publish workflow: it re-runs the full test gate, then publishes to pub.dev. The tag must match the pubspec version or pub.dev rejects it.

License #

MIT — see LICENSE.

1
likes
150
points
40
downloads

Documentation

API reference

Publisher

verified publisherframboos.dev

Weekly Downloads

App-wide, prioritized alert delivery for Flutter. Polls (or receives pushed) alerts from a configurable backend source, tracks what has already been presented, and shows urgent pop-up alerts above every route — or hands inline alert content to the host app to render in its own style. Pure-Dart transport; runs on mobile, desktop, web, and embedded.

Repository (GitHub)
View/report issues

License

MIT (license)

Dependencies

flutter, http, web_socket_channel

More

Packages that depend on app_alerts