super_overlay 0.3.0 copy "super_overlay: ^0.3.0" to clipboard
super_overlay: ^0.3.0 copied to clipboard

Self-managed OverlayEntry dialogs, toasts, popups, loading indicators, and notifications for Flutter.

SuperOverlay #

English | 简体中文

SuperOverlay is a Flutter package for app-level dialogs, loading indicators, toasts, target-attached popups, highlighted guides, and notifications backed by a self-managed OverlayEntry host. It provides typed handles, route and widget ownership, nested-Navigator scoping, back policies, keyboard/focus behavior, and moving-anchor tracking without installing a package-owned navigator key.

Install #

dependencies:
  super_overlay: ^0.3.0

The package requires Dart >=3.7.0 <4.0.0 and Flutter >=3.29.0. Runtime dependencies are limited to the Flutter SDK; go_router is used only as a dev-time compatibility fixture.

Root Integration #

Create one stable SuperOverlayIntegration outside build, then use its matching builder and root observer for that MaterialApp. Dispose the integration with the app root.

// snippet:root-integration:start
class RootOverlayApp extends StatefulWidget {
  const RootOverlayApp({super.key});

  @override
  State<RootOverlayApp> createState() => _RootOverlayAppState();
}

class _RootOverlayAppState extends State<RootOverlayApp> {
  late final SuperOverlayIntegration integration;

  @override
  void initState() {
    super.initState();
    integration = SuperOverlay.integration();
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      builder: integration.builder,
      navigatorObservers: [integration.observer],
      home: const AppHome(),
    );
  }

  @override
  void dispose() {
    integration.dispose();
    super.dispose();
  }
}
// snippet:root-integration:end

SuperOverlay.init() and SuperOverlay.observer remain the compact legacy pair for one stable root. New production integrations should prefer the owned object above because observer ownership and disposal are explicit.

Atomic Root Replacement #

When replacing a complete MaterialApp, give the old and new roots separate, stable integrations. Store both integration pairs outside their respective build methods. A same-frame replacement overlap is frozen until the old root detaches, then the candidate is promoted atomically. Existing handles remain generation-bound and cannot mutate the new root.

Do not reuse one observer in two Navigators, and do not leave both roots mounted beyond the replacement frame. Two live app hosts in one isolate are an unsupported conflict, not a multi-tenant mode.

Nested Navigator Integration #

Create one scoped observer per nested Navigator. Store it outside build, attach it to exactly one Navigator, and dispose it when that Navigator is removed.

// snippet:nested-navigator:start
class NestedCheckoutFlow extends StatefulWidget {
  const NestedCheckoutFlow({super.key, required this.integration});

  final SuperOverlayIntegration integration;

  @override
  State<NestedCheckoutFlow> createState() => _NestedCheckoutFlowState();
}

class _NestedCheckoutFlowState extends State<NestedCheckoutFlow> {
  late final SuperOverlayNavigatorObserver observer;

  @override
  void initState() {
    super.initState();
    observer = widget.integration.navigatorObserver();
  }

  @override
  Widget build(BuildContext context) {
    return Navigator(
      observers: [observer],
      onGenerateRoute:
          (_) => MaterialPageRoute<void>(builder: (_) => const CheckoutHome()),
    );
  }

  @override
  void dispose() {
    observer.dispose();
    super.dispose();
  }
}
// snippet:nested-navigator:end

Call SuperOverlay.of(context) with a context below the Navigator that owns the route. A shell AppBar context above a branch Navigator resolves to the shell or root scope, not the branch.

final handle = SuperOverlay.of(context).dialog.show<bool>(
  builder: (_) => const ConfirmDeleteDialog(),
);

Scoped route-bound dialogs and popups suspend while their route is covered, resume when it becomes current, and close when the owner route or observer is removed. Route-neutral toast, loading, and notification surfaces remain rooted at the one app host.

go_router ShellRoute #

The root GoRouter.observers receives the root observer. Every ShellRoute receives its own scoped observer.

// snippet:shell-route:start
class ShellRouterOwner {
  ShellRouterOwner(this.integration);

  final SuperOverlayIntegration integration;
  late final SuperOverlayNavigatorObserver shellObserver =
      integration.navigatorObserver();
  late final GoRouter router = GoRouter(
    observers: [integration.observer],
    routes: [
      ShellRoute(
        observers: [shellObserver],
        builder: (context, state, child) => Scaffold(body: child),
        routes: [
          GoRoute(path: '/', builder: (context, state) => const ShellHome()),
        ],
      ),
    ],
  );

  void dispose() {
    router.dispose();
    shellObserver.dispose();
  }
}
// snippet:shell-route:end

For StatefulShellRoute.indexedStack, create a distinct observer for every branch and initiate branch-owned commands from a descendant context inside that branch.

// snippet:stateful-shell-branches:start
class StatefulShellRouterOwner {
  StatefulShellRouterOwner(this.integration);

  final SuperOverlayIntegration integration;
  late final SuperOverlayNavigatorObserver firstBranchObserver =
      integration.navigatorObserver();
  late final SuperOverlayNavigatorObserver secondBranchObserver =
      integration.navigatorObserver();
  late final GoRouter router = GoRouter(
    observers: [integration.observer],
    routes: [
      StatefulShellRoute.indexedStack(
        builder:
            (context, state, navigationShell) =>
                Scaffold(body: navigationShell),
        branches: [
          StatefulShellBranch(
            observers: [firstBranchObserver],
            routes: [
              GoRoute(
                path: '/first',
                builder: (context, state) => const FirstBranchHome(),
              ),
            ],
          ),
          StatefulShellBranch(
            observers: [secondBranchObserver],
            routes: [
              GoRoute(
                path: '/second',
                builder: (context, state) => const SecondBranchHome(),
              ),
            ],
          ),
        ],
      ),
    ],
  );

  void dispose() {
    router.dispose();
    firstBranchObserver.dispose();
    secondBranchObserver.dispose();
  }
}
// snippet:stateful-shell-branches:end

The default indexed-stack container supplies Navigator-level TickerMode, so inactive branch overlays release hit testing, semantics, focus, and back priority. A custom stateful-shell container must provide the same exactly-one-active Navigator-level signal.

Command And Handle Basics #

For a non-null tag, OverlayStrategy.stack always creates another entry, keepExisting returns the existing same-surface, same-tag handle, and replaceExisting closes every same-surface, same-tag match before showing the replacement. A null tag has no conflict group.

Keep the returned handle when the calling flow owns the overlay lifecycle:

final loading = SuperOverlay.loading.show(message: 'Syncing profile...');
try {
  await syncProfile();
  SuperOverlay.toast(
    'Saved',
    options: const OverlayToastOptions(
      displayPolicy: OverlayToastDisplayPolicy.replaceLatest,
    ),
  );
} finally {
  await loading.close();
}

Dialogs and popups carry typed results through closed:

late final OverlayHandle<bool> handle;
handle = SuperOverlay.dialog.show<bool>(
  builder: (_) => ConfirmDeleteDialog(
    onDecision: (confirmed) => handle.close(confirmed),
  ),
  options: const OverlayDialogOptions(
    tag: 'delete-confirmation',
    strategy: OverlayStrategy.replaceExisting,
  ),
);

final confirmed = await handle.closed;

Prefer handle.close() for owned content. Use typed global cleanup for application shutdown, tests, or content that cannot receive its handle:

await SuperOverlay.close(target: OverlayCloseTarget.allToasts);
await SuperOverlay.close(
  target: OverlayCloseTarget.allDialogs,
  tag: 'checkout',
  force: true,
);

Back, Focus, And Semantics #

OverlayBackBehavior.dismiss closes the highest-priority consuming overlay, block keeps it visible and blocks the route, and passThrough leaves the back event to the application. Android predictive back is coordinated through the same ModalRoute PopEntry protocol used by PopScope.

On keyboard platforms, Escape is focus-local: it uses the same overlay policy only while keyboard focus is inside the overlay. requestFocus controls only initial focus capture. With requestFocus: false, the page retains focus initially, so Escape remains with the page until focus enters the overlay. SuperOverlay does not install a host-level keyboard dispatcher.

Flutter notifies every PopEntry after a failed pop. An application PopScope or Form callback may therefore also receive didPop == false while SuperOverlay blocks the route. Keep failed-pop callbacks idempotent and avoid destructive side effects until didPop is true.

Modal dialogs and loading surfaces capture focus by default, use a closed-loop focus scope, block background semantics, expose a semantic route, and restore prior focus when possible. semanticsLabel labels the overlay's semantic container and becomes its route label for modal dialogs. Supply labels when the surrounding content does not make the purpose clear:

const OverlayDialogOptions(
  requestFocus: true,
  semanticsLabel: 'Delete confirmation',
  barrierSemanticsLabel: 'Dismiss delete confirmation',
);

Dialogs with consumeEvents: false are non-modal for both pointer input and semantics: the underlying page remains interactive and discoverable, and focus traversal is not trapped inside the overlay. requestFocus controls only initial focus capture; set it to false when the page should retain keyboard focus. Because non-modal traversal is not trapped, traversal can move focus back to the page; Escape then remains with the page.

Popup focus is non-modal by default. Toasts and notifications are live regions and do not steal focus. Application content remains responsible for semantic labels on its own buttons, fields, and custom controls.

Consuming back behavior (dismiss or block) requires an observed ModalRoute. Custom non-ModalRoute routes and dispatchers that bypass Navigator do not have this guarantee; use passThrough or a route-neutral surface there.

Moving Anchored Popups #

An anchored popup follows a mounted targetContext after scrolling, layout, and transform changes. The runtime converts the target into the root Overlay's local coordinates and refreshes only that popup when movement exceeds 0.5 logical pixels. Placement, highlight cutout, and highlight hit testing receive one immutable rectangle snapshot.

SuperOverlay.popup.show<void>(
  targetContext: buttonContext,
  builder: (_) => const FilterMenu(),
  options: const OverlayPopupOptions(
    tag: 'filter-menu',
    alignment: Alignment.bottomCenter,
    strategy: OverlayStrategy.replaceExisting,
  ),
);

An unmounted or invalid target fails closed and removes its registry record. Partial clipping at any Overlay viewport edge remains valid and continues to track. Once the mounted target has no positive-area intersection with the Overlay viewport, it fails closed as unavailable. maskIgnoreArea is different: it remains fixed in overlay-host coordinates and does not move relative to the target.

Refresh Contracts #

OverlayToastDisplayPolicy.refreshActive starts a new toast command in the policy-owned refresh lane or replaces that lane's content. It is not a handle content refresh.

handle.refresh() rebuilds only content owned by that existing handle. Use it for progress or mutable presentation state without starting another command. The runnable example displays both families side by side.

Handle Lifecycle #

OverlayHandle.visible completes after the first rendered frame. It fails with StateError if the command is rejected or closes before rendering, including an invalid popup target or a route removed before first paint. The deliberate exception is OverlayHandle.detached(): it represents a no-op owner, completes visible immediately, and is never visible.

OverlayHandle.closed settles exactly once with the optional result. Repeated close() calls share one close future. A suspended route-bound overlay reports isVisible == false until it resumes.

Supported Topology And Platforms #

SuperOverlay supports one active root host per isolate. The table records the release policy; device evidence status is maintained in the device verification matrix.

Platform Tier Release expectation
Android Supported Automated gates plus recorded edge-to-edge/cutout device evidence before release
iOS Supported Automated gates plus recorded notch/Dynamic Island evidence before release
Web Supported Full example test suite and flutter build web in CI
macOS Best effort Single-window behavior; record a build/manual result to claim it for a release
Windows Best effort Single-window behavior; record a build/manual result to claim it for a release
Linux Best effort Single-window behavior; record a build/manual result to claim it for a release

Supported navigation topologies include one stable MaterialApp, same-frame atomic whole-root replacement, ordinary nested Navigators, ShellRoute, and the default StatefulShellRoute.indexedStack container.

Explicit limitations:

  • simultaneous live MaterialApp hosts in one isolate fail fast;
  • root transitions that keep old and new hosts mounted across multiple frames are unsupported;
  • desktop multi-window routing in one isolate/view registry is unsupported;
  • custom stateful-shell containers without Navigator-level TickerMode cannot provide reliable branch activity;
  • consuming back behavior on a non-ModalRoute is unsupported;
  • physical-device cutout and keyboard evidence must be recorded per release;
  • desktop claims are single-window best effort unless that release records a build and manual result.

Example, Verification, And Maintenance #

The example app demonstrates typed dialog results, dismiss/block/passThrough, nested Navigator suspension and cleanup, moving anchors, and refreshActive versus handle.refresh().

Traceability is recorded in the example coverage matrix. See CONTRIBUTING.md for contributor gates, RELEASE.md for the release checklist, and SECURITY.md for vulnerability reporting policy.

License #

SuperOverlay is MIT licensed. See LICENSE.

0
likes
150
points
11
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Self-managed OverlayEntry dialogs, toasts, popups, loading indicators, and notifications for Flutter.

Repository (GitHub)
View/report issues

License

MIT (license)

Dependencies

flutter

More

Packages that depend on super_overlay