devShakib

loading_kit

A blocking-async overlay that never flickers. Wrap any Future in one call: delayed reveal, minimum display time, reference counting, cancellation, and themed presets.

loading_kit

A blocking-async overlay that never flickers.

Wrap a future in one call. loading_kit decides whether an overlay is even

warranted, holds it long enough to read, counts overlapping requests, settles

into a check or a cross, and cleans up after itself when the route changes.

final user = await Loading.run(  () => api.signIn(email, password),  message: 'Signing in…',  successMessage: 'Welcome back',);

The same 80ms request, then the same 1.2s request, with and without loading_kit

Left: a bool and a Stack. Right: loading_kit. Same requests, fired at the same moment.

Why another one

Most loading overlays are a spinner in a Stack with a bool. They work until

the network is fast, and then they strobe: a request returns in 90ms, the

spinner appears and vanishes inside three frames, and the screen flinches.

Two rules fix that, and they are the reason this package exists.

Nothing paints before the reveal delay. An operation that resolves in under

140ms renders nothing at all — not a spinner, not a success tick. Fast paths

stay visually silent. In the GIF above the left panel gets a single frame of

spinner; the right panel never moves.

Once painted, it holds. An overlay that appeared at 140ms and tore down at

170ms reads as a glitch, so it stays for at least half a second. The wait is

deliberate, and it looks deliberate.

Everything else in the package follows from taking blocking state seriously

rather than from having more spinner shapes.

Install

dependencies:  loading_kit: ^0.1.0

Setup

One line in your app, plus an observer so overlays cannot outlive their screen.

MaterialApp(  builder: LoadingKit.builder(style: LoadingStyle.glass),  navigatorObservers: [LoadingNavigatorObserver()],  home: const HomePage(),);

The host sits above the navigator, so the overlay covers every route — dialogs

and bottom sheets included — and survives transitions underneath it.

Usage

Run a future

final orders = await Loading.run(() => repo.fetchOrders());

run rethrows whatever the task threw, so your error handling is unchanged.

Its future completes only once the overlay has finished leaving — returning

earlier would let you navigate out from under a still-animating overlay, which

is the flicker this package exists to prevent. Pass awaitFeedback: false to

opt out.

Report progress and allow cancelling

await Loading.runTask((task) async {  for (var i = 0; i < files.length; i++) {    task.throwIfCancelled();    task.report((i + 1) / files.length, detail: '${i + 1} of ${files.length}');    await upload(files[i]);  }}, message: 'Uploading…', cancelAfter: const Duration(seconds: 3));

cancelAfter reveals the cancel affordance only once that much time has

passed, so quick operations never offer one. Cancellation is cooperative:

tapping cancel rejects the future with LoadingCancelled immediately, and the

body stops at its next throwIfCancelled().

Drive it by hand

final upload = Loading.show(message: 'Preparing…');upload.update(message: 'Compressing…', progress: 0.2);upload.progress = 0.85;await upload.success('Done');

Time out

await Loading.run(  () => api.slowCall(),  timeout: const Duration(seconds: 20),  errorMessage: 'Timed out',);

Use the indicator alone

LoadingIndicator needs no overlay and no controller.

const LoadingIndicator(size: 48)LoadingIndicator(progress: 0.6)LoadingIndicator(status: LoadingStatus.success)LoadingIndicator(indicatorStyle: LoadingIndicatorStyle.ripple)

Toasts, for things that already happened

Not everything deserves a scrim. A toast reports an outcome without blocking

anything, and dismisses itself:

Loading.toast('Draft saved');Loading.toastSuccess('Order placed');Loading.toastError('Could not sync', detail: 'Retrying in the background');

Toasts never intercept input, stack up to three at a time, and reuse the

resolved theme so they match the overlay.

Block one part of the screen, not all of it

Blacking out the whole app for a form that saves in place is heavy-handed.

LoadingBarrier scopes the overlay to a subtree — and still applies the

timing policy, so a fast save flashes nothing:

LoadingBarrier(  loading: _saving,  message: 'Saving…',  borderRadius: BorderRadius.circular(16),  child: const ProfileForm(),)

A bar instead of a ring

For long operations a bar is easier to read at a glance — the difference

between 60% and 70% is obvious in a line and subtle in a circle:

LoadingStyle.material.copyWith(progressStyle: LoadingProgressStyle.bar)

The outcome still arrives as the glyph, so a finished bar cross-fades to a

check or a cross.

Presets

adaptive (the default) resolves to cupertino on Apple platforms and

material elsewhere. Every preset resolves against the ambient ThemeData, so

light and dark both work with no configuration.

| Preset | Look |

| --- | --- |

| cupertino | Compact, low-contrast card in the iOS idiom |

| material | Tonal Material 3 surface using your primary colour |

| glass | Frosted translucent panel with a luminous edge |

| minimal | Indicator only on a soft scrim — cheapest to paint |

| neon | Dark panel with a saturated, glowing indicator |

The five presets

Indicator styles

Six indeterminate forms. Every one settles into the same check or cross, so

the outcome reads identically no matter which spinner preceded it.

The six indicator styles

LoadingStyle.material.copyWith(indicatorStyle: LoadingIndicatorStyle.bars)

arc · dots · bars · orbit · pulse · ripple

Determinate work always draws as an arc or a bar regardless of this setting —

no pulsing or bouncing form can express "62%".

Or bring your own

The built-in shapes are optional. indicatorBuilder hands the whole slot to a

widget of yours — a Lottie file, a Rive animation, your brand mark, or anything

from another spinner package:

LoadingKit.builder(  style: LoadingStyle.material.copyWith(    indicatorBuilder: (context, spec) => SpinKitCubeGrid(      color: spec.statusColor,      size: spec.size,    ),  ),)

spec carries the resolved status, progress, size, colours and stroke width,

so a custom indicator still tracks your theme.

Override any token without leaving the preset:

LoadingKit.builder(  style: LoadingStyle.cupertino.copyWith(    indicatorColor: brand.teal,    cardRadius: BorderRadius.circular(20),    scrimBlur: 12,  ),)

Tune the timing the same way:

LoadingKit.builder(  timing: const LoadingTiming(    delay: Duration(milliseconds: 180),    minVisible: Duration(milliseconds: 600),  ),)

LoadingTiming.instant disables both rules, and LoadingTiming.relaxed waits

longer before committing for operations you expect to be slow.

Animation speed

LoadingTiming decides when the overlay appears and leaves. LoadingMotion

decides how fast the thing on screen moves once it is there:

LoadingStyle.material.copyWith(motion: LoadingMotion.calm)LoadingStyle.material.copyWith(  motion: const LoadingMotion(spinPeriod: Duration(seconds: 2)),)

standard, brisk and calm are built in. Changing motion is purely

cosmetic — it cannot affect the anti-flicker guarantees, which live in

LoadingTiming.

Nothing is hardcoded

Every constant is a token, and all of them layer on top of any preset:

| Group | Tokens |

| --- | --- |

| Scrim | scrimColor, backdropBlur |

| Card | showCard, cardColor, cardBorderColor, cardBorderWidth, cardRadius, cardShadow, cardPadding, cardMinWidth, maxCardWidth |

| Indicator | indicatorStyle, indicatorSize, indicatorStroke, indicatorColor, trackColor, successColor, errorColor, indicatorGlow, indicatorBuilder |

| Progress | progressStyle |

| Text | messageStyle, detailStyle, cancelStyle, textGap |

| Cancel | cancelMinimumSize, cancelPadding |

| Layout | spacing, alignment |

| Transition | enterCurve, exitCurve, enterScale |

| Motion | motion — 5 durations |

| Toasts | toast — 7 metrics, plus toastExitDuration, defaultToastDuration and maxVisibleToasts on the controller |

What it handles that a bool does not

the last one retires, so an early return cannot strand another request.

running, the spinner stays. No check mark flashes mid-flight.

route beneath them changes, so a spinner cannot get stuck over a screen that

never asked for it.

behind it.

hardware keyboard cannot reach buttons under the scrim.

and its progress, and BlockSemantics hides the blocked app underneath.

fewer animations.

CustomPainter. The arc closes into a ring, crosses to the terminal colour,

and strokes the glyph on inside it — rather than swapping one widget for an

unrelated one.

The arc closing into a check, then a cross

Performance

SizedBox.shrink(): no scrim, no blur, no ticker, no hit-test target.

rebuilds one small subtree. The app subtree is passed through by identity, so

Flutter skips it entirely when loading starts or stops.

repeating ticker rather than leaving it running through the exit animation.

the filter is clipped to the card rather than compositing the whole screen.

saveLayer for the indicator.

Testing

Everything is timer-driven, so timing behaviour is testable on the fake clock

with no real waiting:

testWidgets('a fast call shows nothing', (tester) async {  final controller = LoadingController();  addTearDown(controller.dispose);  final work = controller.run(() => Future.delayed(const Duration(milliseconds: 80)));  await tester.pump(const Duration(milliseconds: 80));  expect(controller.value.visible, isFalse);  await tester.pump(const Duration(milliseconds: 400));  await work;});

One caveat that applies to any indeterminate indicator, Flutter's own included:

a spinning arc schedules a frame forever, so pumpAndSettle() will not settle

while one is on screen. Pump explicit durations instead.

Scoped controllers

The global Loading facade is a convenience for code with no BuildContext.

Where a context is available, prefer the scoped controller — it is ordinary

state rather than shared state, and trivially testable:

await context.loading.run(() => repo.save(draft));

You can also host an overlay over part of the app rather than all of it:

LoadingHost(  registerGlobal: false,  controller: myController,  child: const EditorPane(),)

License

MIT © K M Shahriar Hossain

Regenerating the demos

The GIFs are produced by driving the package through Flutter's own rasterizer

on a fake clock, so they show the real timing frame-accurately rather than

whatever a screen recorder happened to catch.

flutter test tool/record_frames.dart # writes doc/frames/<scene>/*.png

./tool/build_gifs.sh # writes doc/<scene>.gif

loading_kit on pub.dev