A production-ready Flutter button widget with built-in loading animations and status feedback (Success/Error). Perfect for asynchronous operations and form submissions.
- ⏳ Loading State — Built-in progress indicator; button auto-disables during execution.
- ✅ Status Feedback — Success/Error icons shown based on operation result.
- 🎨 Deeply Customizable — Colors, radius, borders, shadows, animations, icons, and more.
- 🔄 Theme Support — Global defaults via
OnProcessButtonDefaultValuesorOnProcessButtonTheme. - 💫 Smooth Animations — Animated size transitions between states.
- 📱 Platform Ready — Android, iOS, Web, macOS, Windows, and Linux.
flutter pub add on_process_button_widgetOr add to pubspec.yaml:
dependencies:
on_process_button_widget: ^2.1.0import 'package:on_process_button_widget/on_process_button_widget.dart';onTap runs your async operation. Everything the button does follows from what you return:
| You return | What the user sees | When onDone fires |
|---|---|---|
true |
Success icon (Icons.done) for statusShowingDuration |
after that delay, with true |
false |
Error icon (Icons.error) for statusShowingDuration |
after that delay, with false |
null |
Nothing — straight back to idle | immediately, with null |
| (it throws) | Nothing — straight back to idle | immediately, with null |
OnProcessButtonWidget(
onTap: () async {
await submitForm();
return true;
},
child: const Text('Submit'),
)Handle your own errors inside onTap. If onTap throws, the button recovers correctly, but the error is then rethrown so it isn't silently swallowed — meaning it surfaces as an uncaught async error in your app (and will fail your widget tests). If a failure should show the error icon rather than blow up, catch it and return false:
onTap: () async {
try {
await api.submitForm(data);
return true;
} catch (e) {
logger.warning('submit failed', e);
return false; // shows the error icon
}
},Return null when you want no status feedback at all — a refresh button, or a tap that navigates away.
Uses Theme.of(context).colorScheme.primary as background and onPrimary for text/icons automatically. No manual color styling needed on Text() or Icon() children — the button controls all color.
OnProcessButtonWidget(
onTap: () async {
await Future.delayed(const Duration(seconds: 2));
return true;
},
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.download),
SizedBox(width: 8),
Text('Download'),
],
),
)OnProcessButtonWidget(
expanded: false,
border: Border.all(color: Theme.of(context).colorScheme.outline),
backgroundColor: Colors.transparent,
iconColor: Theme.of(context).colorScheme.onSurface,
fontColor: Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.normal,
onTap: () async => true,
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.sync, size: 18),
SizedBox(width: 8),
Text('Refresh'),
],
),
)OnProcessButtonWidget(
expanded: false,
border: Border.all(color: Colors.red),
backgroundColor: Colors.red.withValues(alpha: 0.1),
iconColor: Colors.red,
fontColor: Colors.red,
fontWeight: FontWeight.normal,
onTap: () async {
await performDangerousAction();
return true;
},
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.delete, size: 18),
SizedBox(width: 8),
Text('Delete'),
],
),
)OnProcessButtonWidget(
enable: false,
onTap: () async => true,
child: const Text('Disabled'),
)Set isRunning: true to force the loading state without calling onTap. The callback won't be invoked.
OnProcessButtonWidget(
isRunning: isLoading,
child: const Text('Submit'),
)| Style | backgroundColor |
border |
iconColor |
fontColor |
|---|---|---|---|---|
| Filled (default) | (theme primary) | (none) | (theme onPrimary) | (theme onPrimary) |
| Outlined | Colors.transparent |
Border.all(...) |
onSurface |
onSurface |
| Colored action | color.withAlpha(25) |
Border.all(color) |
same color |
same color |
⚠️ Important: Never setcolor:orstyle:directly onText()orIcon()children. The button controls text/icon appearance via its own properties (fontColor,iconColor,textStyle,fontWeight). Manual colors will override the button's color system.
| Property | Type | Default | Description |
|---|---|---|---|
onTap |
Future<bool?> Function()? |
null |
Async handler. Return true=success, false=error, null=skip status. |
onDone |
void Function(bool?)? |
null |
Called after tap completes and status finishes displaying. |
onStatusChange |
void Function(BuildContext?, OnProcessButtonStatus)? |
null |
Called when button status changes (stable/running/success/error). |
child |
Widget? |
null |
Primary button content (Text, Row with Icon+Text, etc.). |
expanded |
bool |
true |
Whether button fills available width. |
backgroundColor |
Color? |
theme primary | Background color. |
iconColor |
Color? |
theme onPrimary | Color of status indicators AND child icons. |
fontColor |
Color? |
theme onPrimary | Color of button text. |
fontWeight |
FontWeight? |
bold | Font weight. |
textStyle |
TextStyle? |
titleMedium | Full text style override. |
border |
BoxBorder? |
null |
Border (set for outlined style). |
borderRadius |
BorderRadius? |
8.0 circular | Corner radius. |
boxShadow |
List<BoxShadow>? |
null |
Shadow under the button. |
contentPadding |
EdgeInsetsGeometry? |
horiz:12, vert:4 | Inner padding. |
enable |
bool? |
true |
Whether button is interactive. |
onRunningWidget |
Widget? |
CircularProgressIndicator |
Shown during loading. |
onSuccessWidget |
Widget? |
Icons.done |
Shown on success. |
onErrorWidget |
Widget? |
Icons.error |
Shown on error. |
showRunningStatusWidget |
bool? |
true |
Whether to show the running status widget. |
roundBorderWhenRunning |
bool? |
true |
Circular border during non-stable states. |
expandedIcon |
bool? |
null |
Whether status icon fills available width. |
statusShowingDuration |
Duration? |
2 seconds | How long success/error is displayed. |
isRunning |
bool |
false |
Manually force running/loading state. |
For the complete property reference including interaction callbacks, mouse/focus/highlight, animation, and text styling, see the API Reference section.
The button provides dedicated properties for all visual aspects. Do not style Text() or Icon() children directly.
OnProcessButtonWidget(
backgroundColor: Colors.deepPurple,
iconColor: Colors.white,
fontColor: Colors.white,
borderRadius: BorderRadius.circular(12),
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
onTap: () async => true,
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.save),
SizedBox(width: 8),
Text('Save Changes'),
],
),
)Your controller methods should return Future<bool?>:
class AuthController {
Future<bool?> login() async {
try {
await authService.login(email, password);
return true;
} catch (e) {
return false;
}
}
}
// In your view:
OnProcessButtonWidget(
onTap: controller.login,
child: const Text('Login'),
)Show a confirmation dialog before executing, or chain operations:
OnProcessButtonWidget(
onTap: () async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Confirm'),
content: const Text('Proceed with this action?'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancel')),
TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('OK')),
],
),
);
if (confirmed != true) return false;
await controller.dangerousAction();
return true;
},
onDone: (isSuccess) {
if (isSuccess == true) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Action completed')),
);
}
},
child: const Text('Dangerous Action'),
)Set app-wide defaults before runApp. Properties are static and persist for the application lifetime.
void main() {
OnProcessButtonDefaultValues.borderRadius = BorderRadius.circular(12);
OnProcessButtonDefaultValues.expandedIcon = true;
OnProcessButtonDefaultValues.roundBorderWhenRunning = false;
OnProcessButtonDefaultValues.onStatusChange = (context, status) {
if (context == null) return;
if (status == OnProcessButtonStatus.running) {
showDialog(
context: context,
barrierDismissible: false,
builder: (_) => const AlertDialog(
title: Text('Processing'),
content: Text('Please wait...'),
),
);
} else if (status == OnProcessButtonStatus.stable) {
Navigator.of(context).pop();
}
};
runApp(const MyApp());
}Scoped defaults via inherited widget. Resolution order: widget param → OnProcessButtonTheme.of(context) → OnProcessButtonDefaultValues → default.
OnProcessButtonThemeProvider(
data: const OnProcessButtonThemeData(
borderRadius: BorderRadius.all(Radius.circular(16)),
iconColor: Colors.white,
),
child: MyWidget(),
)
⚠️ Don't put aFocusNodein the theme or the global defaults. AFocusNodeis stateful and must belong to a single widget; shared across buttons it misbehaves as soon as two are mounted. PassfocusNodeper widget.
| Behavior | What to expect |
|---|---|
| Taps during a run are dropped | While the button is running or showing a status, further taps are ignored. This is a built-in concurrency guard — you don't need your own "already submitting" flag. |
onDone is delayed |
On a true/false result it fires after statusShowingDuration (default 2s), not when onTap resolves. Use it to chain follow-up work, not for anything latency-sensitive. |
isRunning: true doesn't call onTap |
It forces the loading visual only. Use it when something else drives the loading state. |
enable: false disables all press callbacks |
Since 2.1.0 this covers long-press, double-tap, tap up/down/cancel and secondary taps — not just onTap. Hover and focus callbacks still fire. It applies no "disabled" styling, so set your own colors if you want it to look disabled. |
expanded defaults to true |
The button fills the available width. Set expanded: false for an intrinsic-width button. |
autofocus defaults to false |
Changed in 2.1.0 — it used to be true and would steal focus from an autofocused TextField. Pass autofocus: true explicitly if you relied on the old behavior. |
onStatusChange may hand you a null context |
It fires on every transition, but passes null for the BuildContext if the widget is gone. Null-check before using it. |
textStyle is a full override |
If you pass it, fontColor and fontWeight no longer apply — fold the color into the TextStyle. |
The status delay is real time in tests, so a bare pump() won't reach the end state. Shorten the duration and settle:
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: OnProcessButtonWidget(
statusShowingDuration: const Duration(milliseconds: 10),
onTap: () async => true,
child: const Text('Submit'),
),
),
));
await tester.tap(find.text('Submit'));
await tester.pump(); // spinner is now showing
expect(find.byType(CircularProgressIndicator), findsOneWidget);
await tester.pumpAndSettle(); // runs through success → stableIf the code under test has an onTap that throws, the rethrown error reaches the zone and fails the test outright — tester.takeException() won't catch it, since that only surfaces errors reported through FlutterError. Wrap the interaction:
final caught = <Object>[];
await runZonedGuarded(() async {
await tester.tap(find.text('Submit'));
await tester.pump();
}, (Object error, StackTrace stack) => caught.add(error));Better still, catch inside onTap and return false so this never comes up.
Note that OnProcessButtonDefaultValues is global with no reset() — anything a test sets leaks into later tests in the same run, so clear it in tearDown.
| All Features | Hover Effects |
|---|---|
![]() |
![]() |
| Request Status | Double Process |
![]() |
![]() |
| Custom Styles | Card Mode |
![]() |
![]() |
enum OnProcessButtonStatus { stable, running, success, error }| Value | Meaning |
|---|---|
stable |
Idle state, showing child. |
running |
onTap is executing, showing onRunningWidget. |
success |
onTap returned true, showing onSuccessWidget. |
error |
onTap returned false, showing onErrorWidget. |
The main button widget. A StatefulWidget with all properties passed via the constructor.
stable → (tap) → running → (result=true) → success → (delay) → stable
→ (result=false) → error → (delay) → stable
→ (result=null) → stable (skip)
| Property | Type | Default |
|---|---|---|
onTap |
Future<bool?>? Function()? |
null |
onDone |
void Function(bool?)? |
null |
onStatusChange |
void Function(BuildContext?, OnProcessButtonStatus)? |
null |
| Property | Type | Default |
|---|---|---|
onLongPress |
void Function()? |
null |
onTapUp |
void Function(TapUpDetails)? |
null |
onTapDown |
void Function(TapDownDetails)? |
null |
onTapCancel |
void Function()? |
null |
onDoubleTap |
void Function()? |
null |
onSecondaryTap |
void Function()? |
null |
onSecondaryTapUp |
void Function(TapUpDetails)? |
null |
onSecondaryTapDown |
void Function(TapDownDetails)? |
null |
onSecondaryTapCancel |
void Function()? |
null |
| Property | Type | Default |
|---|---|---|
onHover |
void Function(bool isEnter)? |
null |
onHovering |
void Function(PointerHoverEvent)? |
null |
onFocusChange |
void Function(bool isFocused)? |
null |
onHighlightChanged |
void Function(bool isHighlighted)? |
null |
mouseCursor |
MouseCursor? |
null |
focusNode |
FocusNode? |
null |
| Property | Type | Default | Notes |
|---|---|---|---|
backgroundColor |
Color? |
theme primary | Runtime theme fallback |
iconColor |
Color? |
theme onPrimary | Controls status icons AND child icons |
fontColor |
Color? |
theme onPrimary | |
fontWeight |
FontWeight? |
bold |
|
textStyle |
TextStyle? |
titleMedium |
Full override; if null, builds from fontColor + fontWeight |
border |
BoxBorder? |
null |
|
borderRadius |
BorderRadius? |
8.0 circular |
|
boxShadow |
List<BoxShadow>? |
null |
When set, background becomes surface color |
focusColor |
Color? |
null |
|
splashColor |
Color? |
null |
|
highlightColor |
Color? |
null |
|
hoverColor |
Color? |
null |
|
useMaterial3 |
bool? |
true |
Controls which theme colors are referenced |
| Property | Type | Default | Notes |
|---|---|---|---|
expanded |
bool |
true |
Fills width when true |
expandedIcon |
bool? |
null |
Falls back to expanded |
enable |
bool? |
true |
Disabled = all tap/press callbacks become null; hover/focus still fire |
enableFeedback |
bool? |
true |
|
autofocus |
bool? |
false |
Enabling it on several buttons in one focus scope makes them compete for focus |
width |
double? |
null |
Prefer constraints |
height |
double? |
null |
Prefer constraints |
constraints |
BoxConstraints? |
theme derived | Min height = Theme.buttonTheme.height minus border widths |
contentPadding |
EdgeInsetsGeometry? |
horiz:12, vert:4 |
|
margin |
EdgeInsetsGeometry? |
null |
|
alignment |
AlignmentGeometry? |
Alignment.center |
|
iconHeight |
double? |
content-based |
| Property | Type | Default |
|---|---|---|
animationDuration |
Duration? |
500ms |
animationAlignment |
AlignmentGeometry? |
Alignment.center |
statusShowingDuration |
Duration? |
2s |
roundBorderWhenRunning |
bool? |
true |
| Property | Type | Default |
|---|---|---|
onRunningWidget |
Widget? |
CircularProgressIndicator (themed) |
onSuccessWidget |
Widget? |
Icon(Icons.done) (themed) |
onErrorWidget |
Widget? |
Icon(Icons.error) (themed) |
showRunningStatusWidget |
bool? |
true |
| Property | Type | Default |
|---|---|---|
textAlign |
TextAlign? |
TextAlign.center |
textOverflow |
TextOverflow? |
TextOverflow.clip |
textHeightBehavior |
TextHeightBehavior? |
null |
textMaxLines |
int? |
null |
textWrap |
bool? |
true |
textWidthBasis |
TextWidthBasis? |
TextWidthBasis.parent |
| Property | Type | Default |
|---|---|---|
isRunning |
bool |
false |
splashFactory |
InteractiveInkFeatureFactory? |
null |
child |
Widget? |
SizedBox() |
Static class with nullable fields for all OnProcessButtonWidget properties. Set before runApp(). Values persist for the application lifetime — there is no reset() method.
OnProcessButtonDefaultValues.borderRadius = BorderRadius.circular(12);
OnProcessButtonDefaultValues.expandedIcon = true;
OnProcessButtonDefaultValues.roundBorderWhenRunning = false;Immutable theme data class holding all default values. Use with OnProcessButtonTheme.
const OnProcessButtonThemeData(
borderRadius: BorderRadius.all(Radius.circular(16)),
iconColor: Colors.white,
)Includes a copyWith() method for deriving modified instances:
final theme = const OnProcessButtonThemeData(iconColor: Colors.white);
final modified = theme.copyWith(iconColor: Colors.black);Inherited widget providing scoped defaults. Retrievable via OnProcessButtonTheme.of(context).
Convenience StatelessWidget wrapping OnProcessButtonTheme.
OnProcessButtonThemeProvider(
data: const OnProcessButtonThemeData(borderRadius: ...),
child: MyWidget(),
)BSD 3-Clause License — see LICENSE for details.
Made with ❤️ by Shajedur Rahman Panna





