app_alerts 0.1.0
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 #
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 configuredonOpenUrl), 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.
Alert URLs (deep links and external sites) #
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
onOpenUrlhandler, URL alerts fall back to a single OK — a Go button that goes nowhere is never shown. - Inline alerts receive the URL as
alert.urlfor your own UI to use however it likes — show it, hide it, or route it through the same app-wide handler withAppAlerts.instance.openUrl(alert). - A handler that throws lands on
lastErrorinstead 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:
-
Publish
0.1.0manually, 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 -
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 tov{{version}}.
Every release after that:
-
Bump
version:inpubspec.yamland add aCHANGELOG.mdentry. -
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.