diff --git a/.github/workflows/dart.yaml b/.github/workflows/dart.yaml index 336348f..0432ec5 100644 --- a/.github/workflows/dart.yaml +++ b/.github/workflows/dart.yaml @@ -1,31 +1,44 @@ -name: Build +name: CI on: push: + branches: [main] pull_request: - schedule: - # runs the CI everyday at 10AM - - cron: "0 10 * * *" jobs: - setup: + build: timeout-minutes: 15 runs-on: ubuntu-latest + strategy: + matrix: + channel: [stable, beta] + fail-fast: false steps: - - name: "Git Checkout" - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - - name: "Install Flutter" - uses: subosito/flutter-action@v2 + - uses: subosito/flutter-action@v2 + with: + channel: ${{ matrix.channel }} - name: Install dependencies run: flutter pub get - - name: Check format - run: flutter format --set-exit-if-changed . + - name: Check formatting + run: dart format --line-length 100 --set-exit-if-changed . - name: Analyze run: flutter analyze - - name: Run tests - run: flutter test + - name: Test with coverage + run: flutter test --coverage + + - name: Upload coverage + if: matrix.channel == 'stable' + uses: actions/upload-artifact@v4 + with: + name: coverage + path: coverage/lcov.info + + - name: Validate publish (dry run) + if: matrix.channel == 'stable' + run: flutter pub publish --dry-run diff --git a/.gitignore b/.gitignore index 7161313..5c5d214 100644 --- a/.gitignore +++ b/.gitignore @@ -81,3 +81,6 @@ build/ pubspec.lock **/pubspec.lock example/pubspec.lock + +# Coverage output +coverage/ diff --git a/.metadata b/.metadata index 13a1909..3a40cbc 100644 --- a/.metadata +++ b/.metadata @@ -4,7 +4,7 @@ # This file should be version controlled and should not be manually edited. version: - revision: 63062a64432cce03315d6b5196fda7912866eb37 - channel: dev + revision: stable + channel: stable project_type: package diff --git a/.pubignore b/.pubignore index fa3282a..2c3be47 100644 --- a/.pubignore +++ b/.pubignore @@ -1,4 +1,5 @@ .idea/ .vscode/ -screenshots/ -build/ \ No newline at end of file +build/ +coverage/ +tool/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 93d93f6..f48b724 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,164 +1,4 @@ -# [3.0.0+1] - 2022-02-13 +# 1.0.0 -- Fix `Undefined name 'optionalTypeArgs'.` Also adds back the hard dep on freezed_annotation. - -## [3.0.0] - 2022-02-13 - -- Added notification functionality. The error variant now passes through the error and stacktrace which would be needed for a notification to be useful. This is a breaking change as the error widget now takes an error and stacktrace as arguments. - -## [2.3.0+3] - 2023-02-13 - -- Not pushed - -## [2.3.0+2] - 2022-05-24 - -- Added back freezed_annotation as a hard dep. - -## [2.3.0+1] - 2022-05-24 - -- Fixed onError never being called when an error occurs - Credit @jonjomckay - -## [2.3.0] - 2022-05-24 - -- Removed freezed dependency and added new callbacks @mergehez -- Upgrade to freezed 2 (excluding user now) @quantosapplications @esenmx - -## [2.2.0+1] - 2022-01-28 - -Ignore .vscode, update versions and re-export button state. Credit @esenmx - -## [2.2.0] - 2022-01-07 - -Got rid of hook version as it was complicating a simple package and likely discouraging possible contributors. - -## [2.1.4] - 2021-09-28 - -Fix manual button state changes. Credit to @iliser - -## [2.1.3+9] - 2021-09-28 - -Upgraded deps - -## [2.1.3+8] - 2021-09-28 - -Upgraded deps - -## [2.1.3+7] - 2021-09-08 - -Fixed github actions - -## [2.1.3+6] - 2021-08-31 - -Trying to fix pub stuff - -## [2.1.3+5] - 2021-08-27 - -Trying to fix pub stuff - -## [2.1.3+4] - 2021-08-27 - -Fix home page - -## [2.1.3+3] - 2021-08-12 - -Have proper types exported - -## [2.1.3+2] - 2021-08-12 - -Fix possibility of nested async button builders conflicting with each other due to matching keyed subtrees. In order to avoid this situation, the parent key of the async_button_builder is used in the creation of sub widgets. - -## [2.1.3+1] - 2021-06-25 - -Was not using proper sucessDuration when setting timeouts (regression) - -## [2.1.3] - 2021-06-18 - -Tests and builds against stable. flutter_lints now used in place of pedantic - -## [2.1.2+1] - 2021-06-12 - -Fixes analysis errors so I stop getting annoying emails - -## [2.1.2] - 2021-05-24 - -Errors from onpressed will throw with orignal call stack - -## [2.1.1+2] - 2021-04-14 - -Makes onpressed nullable similar to other button's behaviour - -## [2.1.1+1] - 2021-04-14 - -Add documentation on how to handle timeouts - -## [2.1.1] - 2021-04-14 - -ValueKeys are now no longer required in order to differentiate children - -## [2.1.0] - 2021-02-12 - -Remove null-safety prefix as it's now in stable - -## [2.0.9-nullsafety.0] - 2021-02-12 - -Trying to bump version again to go over stable - -## [2.0.8-nullsafety.0] - 2021-02-12 - -Trying to bump version again to go over stable - -## [2.0.7-nullsafety.8] - 2021-02-12 - -Republish so current nullsafe becomes main. My mistake - -## [2.0.2-nullsafety.7] - 2021-02-12 - -Added a typedef in place of inline type on function. Added doc to `builder` field - -## [2.0.2-nullsafety.6] - 2021-02-09 - -Renamed Union classes to match that of factory constructor name (Should not be breaking). - -## [2.0.2-nullsafety.5] - 2021-02-01 - -Removed example integration test - -## [2.0.2-nullsafety.4] - 2021-01-26 - -Adds tests and test coverage. Adds on dispose to cancel timer. - -## [2.0.2-nullsafety.3] - 2021-01-25 - -Removes dangling controller and cleans up main code up a bit - -## [2.0.2-nullsafety.2] - 2021-01-25 - -Fixes wrong image path - -## [2.0.1-nullsafety.2] - 2021-01-25 - -Renames fields to better match standards of other buttons. - -## [2.0.0-dev.1] - 2021-01-25 - -Breaking. Replaces the fourth argument with a sealed union that better allows for directly managing states, removes unnecessary arguments such as padding, adds transition builders for custom transitions. Currently still includes `AnimatedSize`. - -## [1.0.0-nullsafety.0] - 2021-01-12 - -Breaking. Adds a fourth argument of loading to the builder. This removes the value notifier that was difficult or confusing to manage in actual usage in my use cases. The argument isLoading is now a `bool`. This also adds a `disabled` field in order to set that on construction. - -## [0.1.0-nullsafety.0] - 2021-01-12 - -Replaces the bool type of isLoading for a `ValueNotifier`. - -## [0.0.1-nullsafety.2] - 2021-01-12 - -Adds analysis options and removes unnecessary typedef - -## [0.0.1-nullsafety.1] - 2021-01-12 - -Adds docs to parameters and removes use of unnecessary undescore internal variables. - -## [0.0.1-nullsafety.0] - 2021-01-11 - -Holds basic builder AsyncButtonBuilder. No tests yet. `NNBD` is supported +Initial release. Renamed from `async_button_builder` to `material_async_button` +with a redesigned, theme-aware API. diff --git a/LICENSE b/LICENSE index 8da5f16..4c996a4 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2021 Rex Magana +Copyright (c) 2026 Mehmet Esen Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 43aea9e..8fa3f44 100644 --- a/README.md +++ b/README.md @@ -1,191 +1,242 @@ -# async_button_builder +# material_async_button -AsyncButtonBuilder offers a simple way to extend any type of button with an asynchronous aspect. It allows adding loading, disabled, errored and completed states (with fluid animation between each) on top of buttons that perform asynchronous tasks. +Drop-in async wrappers for Flutter Material buttons. Adds **loading**, +**success**, and **error** statuses to `ElevatedButton`, `FilledButton`, +`OutlinedButton`, `TextButton`, and `IconButton` — without forcing you to +build a project-wide wrapper widget. -## Getting Started +```dart +ElevatedAsyncButton( + onPressed: () async => api.save(), + child: const Text('Save'), +) +``` -Include the package: +That's it. The button shows a spinner while `save()` runs and re-enables +when it returns or throws. + +## Install ```yaml - async_button_builder: +dependencies: + material_async_button: ^1.0.0 ``` -Wrap the builder around a button, passing the onPressed and child element to builder instead of the button directly. These two are the only required fields. +Requires Dart `^3.10.0` (any Flutter SDK shipping Dart 3.10+). + +## Why + +Most apps end up writing their own `DefaultAsyncButton` wrapper to share +loading-spinner widgets, durations, and transition curves across screens. +This package gives you that wrapper as a [`ThemeExtension`][th]: ```dart -AsyncButtonBuilder( - child: Text('Click Me'), - onPressed: () async { - await Future.delayed(Duration(seconds: 1)); - }, - builder: (context, child, callback, _) { - return TextButton( - child: child, - onPressed: callback, - ); - }, -), +MaterialApp( + theme: ThemeData( + extensions: [AsyncButtonTheme.material()], + ), +) ``` -

- -

+Configure once, every `*AsyncButton` in the app picks it up. Override per +button when you need to. -The fourth value in the builder allows you listen to the loading state. This can be used to conditionally style the button. This package depends `freezed` in order to create a sealed union to better handle the possible states. +[th]: https://api.flutter.dev/flutter/material/ThemeExtension-class.html -> NOTE (Breaking change): As of v3.0.0, error now takes the error and stack trace as arguments. +## Material wrappers -```dart -AsyncButtonBuilder( - child: Text('Click Me'), - loadingWidget: Text('Loading...'), - onPressed: () async { - await Future.delayed(Duration(seconds: 1)); - - // See the examples file for a way to handle timeouts - throw 'yikes'; - }, - builder: (context, child, callback, buttonState) { - final buttonColor = buttonState.when( - idle: () => Colors.yellow[200], - loading: () => Colors.grey, - success: () => Colors.orangeAccent, - error: (err, stack) => Colors.orange, - ); - - return OutlinedButton( - child: child, - onPressed: callback, - style: OutlinedButton.styleFrom( - primary: Colors.black, - backgroundColor: buttonColor, - ), - ); - }, -), -``` +| Material | Async counterpart | Variants | +| ---------------- | ---------------------- | --------------------------------------------------- | +| `ElevatedButton` | `ElevatedAsyncButton` | `.icon` | +| `FilledButton` | `FilledAsyncButton` | `.tonal`, `.icon`, `.tonalIcon` | +| `OutlinedButton` | `OutlinedAsyncButton` | `.icon` | +| `TextButton` | `TextAsyncButton` | `.icon` | +| `IconButton` | `IconAsyncButton` | `.filled`, `.filledTonal`, `.outlined` | -

- -

+Every Material constructor is mirrored. All Material parameters (`style`, +`focusNode`, `autofocus`, `clipBehavior`, `statesController`, etc.) are +forwarded verbatim. -You can also drive the state of the button yourself using the `buttonState` field: +## Theming + +`AsyncButtonTheme` is a `ThemeExtension`. Resolution order for any +field is **per-widget value → theme value → built-in fallback**. ```dart -AsyncButtonBuilder( - buttonState: ButtonState.completing(), - // ... -), +ThemeData( + extensions: [ + AsyncButtonTheme( + switchDuration: const Duration(milliseconds: 200), + successDisplayDuration: const Duration(milliseconds: 800), + errorDisplayDuration: const Duration(milliseconds: 800), + successChild: const Icon(Icons.check), + errorChild: const Icon(Icons.error_outline), + animateSize: true, + hapticOn: HapticOn.both, + announceSemantics: true, + ), + ], +) ``` -## Notifications +Or grab the opinionated baseline: + +```dart +ThemeData(extensions: [AsyncButtonTheme.material()]) +``` -As of v3.0.0, you can now wrap a higher level parent to handle notifications that come from buttons. Why not use something like `runZonedGuarded`? Notification bubbling handles not only the error but the state of the button. If you'd like, for example, to trigger a circular spinner in the center of the app notifiying the user that something is happening, you can do so by listening to the `AsyncButtonNotification` and then using the `buttonState` to determine what to do. +## Status pattern matching -It might also be a good idea to separate the errors that come from button presses and those that are not. An error wants to see why a button press silently failed but might not need to know why a background fetch failed. +`AsyncButtonStatus` is sealed. The error variant carries the error and +stack trace as fields — destructure them inline: ```dart -MaterialApp( - home: NotificationListener( - onNotification: (notification) { - notification.buttonState.when( - idle: () => // nothing -> you could use a maybeWhen as well - loading: () => // show circular loading widget? - success: () => // show success snackbar? - error: (_, __) => // show error snackbar? - ); - - // Tells the notification to stop bubbling - return true; +AsyncButton( + onPressed: doWork, + child: const Text('Go'), + builder: (context, child, callback, status) => MyButton( + onTap: callback, + color: switch (status) { + AsyncButtonStatusIdle() => Colors.blue, + AsyncButtonStatusLoading() => Colors.grey, + AsyncButtonStatusSuccess() => Colors.green, + AsyncButtonStatusError() => Colors.red, }, - // This async button can be nested arbitrarily deep* - child: AsyncButtonBuilder( - duration: duration, - errorDuration: const Duration(milliseconds: 100), - errorWidget: const Text('error'), - onPressed: () async { - throw ArgumentError(); - }, - builder: (context, child, callback, state) { - return TextButton(onPressed: callback, child: child); - }, - child: const Text('click me'), - ), + child: child, ), ) - -// See NotificationListener for more information ``` -To disable the notifications, you can pass `false` to `notifications`. +`AsyncButton` is the low-level escape hatch. Use it when none of the +Material wrappers fit. -## Customization +## Error payload -`async_button_builder` even works for custom buttons. You can define your own widgets for loading, error, and completion as well as define the transitions between them. This example is a little verbose but shows some of what's possible. +The error variant owns its thrown payload. Render it inline in the builder: ```dart -AsyncButtonBuilder( - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16.0, - vertical: 8.0, - ), - child: Text( - 'Click Me', - style: TextStyle(color: Colors.white), - ), - ), - loadingWidget: Padding( - padding: const EdgeInsets.all(8.0), - child: SizedBox( - height: 16.0, - width: 16.0, - child: CircularProgressIndicator( - valueColor: AlwaysStoppedAnimation(Colors.white), - ), - ), - ), - successWidget: Padding( - padding: const EdgeInsets.all(4.0), - child: Icon( - Icons.check, - color: Colors.purpleAccent, - ), - ), - onPressed: () async { - await Future.delayed(Duration(seconds: 2)); +AsyncButton( + onPressed: () async => repo.submit(), + builder: (context, child, callback, status) => switch (status) { + AsyncButtonStatusError(:final error) => + Text('failed: $error'), + _ => MyButton(onTap: callback, child: child), }, - loadingSwitchInCurve: Curves.bounceInOut, - loadingTransitionBuilder: (child, animation) { - return SlideTransition( - position: Tween( - begin: Offset(0, 1.0), - end: Offset(0, 0), - ).animate(animation), - child: child, - ); - }, - builder: (context, child, callback, state) { - return Material( - color: state.maybeWhen( - success: () => Colors.purple[100], - orElse: () => Colors.blue, - ), - // This prevents the loading indicator showing below the - // button - clipBehavior: Clip.hardEdge, - shape: StadiumBorder(), - child: InkWell( - child: child, - onTap: callback, - ), - ); + child: const Text('Submit'), +) +``` + +Or react externally: + +```dart +ValueListenableBuilder( + valueListenable: controller, + builder: (_, status, _) => switch (status) { + AsyncButtonStatusError(:final error) => Text('$error'), + _ => const SizedBox.shrink(), }, -), +) +``` + +For one-shot notifications (snackbar, log), `onError`: + +```dart +ElevatedAsyncButton( + onPressed: () async => repo.submit(), + onError: (error, stackTrace) => log.warn('$error'), + errorChild: const Icon(Icons.error_outline), + child: const Text('Submit'), +) +``` + +## External control + +`AsyncButtonController` is a `ValueListenable` plus +imperative methods. Use it for **form keyboard "Done"**, parent-owned +state, cross-widget reactions, and tests. + +```dart +final controller = AsyncButtonController(); // dispose like any ChangeNotifier + +TextField( + textInputAction: TextInputAction.done, + onSubmitted: (_) => controller.trigger(), +) +ElevatedAsyncButton( + controller: controller, + onPressed: submit, + child: const Text('Submit'), +) + +// any time: +controller.trigger(); // run onPressed from outside +controller.invalidate('server rejected'); // force error +controller.markSuccess(); // force success +controller.reset(); // back to idle + +// inspect: +controller.value; // AsyncButtonStatus (sealed) +// Pattern-match value for the error payload: +if (controller.value case AsyncButtonStatusError(:final error)) { + log.warn('$error'); +} ``` -

- -

+## Defaults + +When no `AsyncButtonTheme` extension is registered, `AsyncButtonTheme.of` +falls back to `AsyncButtonTheme.material()`: + +| Status | Default UI | +| ---------- | ------------------------------------------------------- | +| idle | your `child` | +| loading | 16×16 `CircularProgressIndicator` | +| success | `Icons.check`, displayed for 800ms | +| error | `Icons.error`, displayed for 800ms | + +Opt out of the baseline by registering `AsyncButtonTheme.empty` on the +theme, then set only the fields you care about. Per-widget overrides +(e.g. `successChild:` on a single button) always win. + +## Features + +- `confirmBeforePress` — gate `onPressed` behind a confirmation `Future` +- `onSuccess` / `onError` / `onStateChanged` — fire-and-forget callbacks +- `errorChild` — static widget shown during error status +- `cooldownDuration` — disable the button briefly after success to prevent + double-submit +- `hapticOn` — light haptic on success/error +- `announceSemantics` — `SemanticsService.sendAnnouncement` for screen readers +- `rethrowErrors` — rethrow from `controller.trigger()` so callers can + `try/catch` while the UI also shows the error + +## Migrating from `async_button_builder` + +After: + +1. `dependencies: async_button_builder: ^3.0.0` → `material_async_button: ^1.0.0` +2. `import 'package:async_button_builder/async_button_builder.dart'` + → `import 'package:material_async_button/material_async_button.dart'` + +Then: + +- `AsyncButtonBuilder` → `AsyncButton` +- `AsyncButtonState` (sealed) → `AsyncButtonStatus` (still sealed; renamed + for clarity and re-prefixed variants `AsyncButtonStatusIdle`, + `AsyncButtonStatusLoading`, `AsyncButtonStatusSuccess`, + `AsyncButtonStatusError(error, stackTrace)`). +- `state.error` → destructure the error variant: + `AsyncButtonStatusError(:final error)`. +- The previous `errorBuilder` is gone — pattern-match the status in your + `builder` callback, or render `errorChild`. + +## Claude Code skill + +A Claude Code skill that teaches Claude to use this package idiomatically +lives in the GitHub repo at +[`tool/claude/flutter-material-async-button/SKILL.md`](https://github.com/esenmx/material_async_button/blob/main/tool/claude/flutter-material-async-button/SKILL.md). +Copy it into `.claude/skills/` in your project. + +## License -Issues and PR's welcome +MIT diff --git a/analysis_options.yaml b/analysis_options.yaml index 38e6292..fb902e3 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1 +1,7 @@ -include: package:lint/package.yaml +include: package:very_good_analysis/analysis_options.yaml + +analyzer: + exclude: + - example/** + errors: + always_put_required_named_parameters_first: ignore diff --git a/coverage/lcov.info b/coverage/lcov.info deleted file mode 100644 index 422cb7f..0000000 --- a/coverage/lcov.info +++ /dev/null @@ -1,107 +0,0 @@ -SF:lib/src/async_button_builder.dart -DA:104,1 -DA:138,1 -DA:143,1 -DA:144,1 -DA:152,1 -DA:154,3 -DA:155,1 -DA:158,0 -DA:160,0 -DA:161,0 -DA:162,0 -DA:166,0 -DA:169,1 -DA:171,2 -DA:173,1 -DA:176,1 -DA:178,1 -DA:179,2 -DA:180,2 -DA:181,2 -DA:187,2 -DA:188,1 -DA:190,2 -DA:192,2 -DA:193,1 -DA:195,1 -DA:197,2 -DA:198,2 -DA:205,0 -DA:206,0 -DA:210,0 -DA:217,0 -DA:223,1 -DA:225,2 -DA:226,2 -DA:227,2 -DA:228,3 -DA:229,3 -DA:230,3 -DA:231,3 -DA:233,2 -DA:234,3 -DA:235,3 -DA:236,3 -DA:237,3 -DA:239,2 -DA:240,3 -DA:241,3 -DA:242,3 -DA:243,3 -DA:245,2 -DA:246,2 -DA:247,2 -DA:248,2 -DA:250,2 -DA:251,2 -DA:254,2 -DA:255,2 -DA:258,2 -DA:259,2 -DA:265,2 -DA:270,2 -DA:271,1 -DA:272,2 -DA:273,2 -DA:274,2 -DA:275,2 -DA:276,2 -DA:280,2 -DA:284,2 -DA:285,2 -DA:286,1 -DA:289,2 -DA:290,1 -DA:293,1 -DA:295,2 -DA:296,1 -DA:298,1 -DA:299,2 -DA:300,2 -DA:301,1 -DA:304,3 -DA:306,0 -DA:307,0 -DA:311,2 -DA:312,1 -DA:314,1 -DA:315,2 -DA:316,2 -DA:317,1 -DA:320,3 -DA:322,0 -DA:323,0 -DA:329,1 -DA:331,1 -DA:333,1 -DA:337,1 -DA:338,2 -DA:340,1 -DA:341,2 -DA:343,1 -DA:344,2 -DA:345,1 -LF:103 -LH:90 -end_of_record diff --git a/example/analysis_options.yaml b/example/analysis_options.yaml deleted file mode 100644 index f9b3034..0000000 --- a/example/analysis_options.yaml +++ /dev/null @@ -1 +0,0 @@ -include: package:flutter_lints/flutter.yaml diff --git a/example/lib/main.dart b/example/lib/main.dart index 6210ff0..ea858a1 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,147 +1,219 @@ -import 'package:async_button_builder/async_button_builder.dart'; import 'package:flutter/material.dart'; +import 'package:material_async_button/material_async_button.dart'; void main() => runApp(const MyApp()); class MyApp extends StatelessWidget { - const MyApp({Key? key}) : super(key: key); + const MyApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( - title: 'Flutter Demo', - theme: ThemeData.dark(), - home: const MyHomePage(), + title: 'material_async_button demo', + theme: ThemeData( + colorSchemeSeed: Colors.indigo, + useMaterial3: true, + extensions: [AsyncButtonTheme.material()], + ), + home: const HomePage(), ); } } -class MyHomePage extends StatefulWidget { - const MyHomePage({Key? key}) : super(key: key); +class HomePage extends StatefulWidget { + const HomePage({super.key}); @override - State createState() => _MyHomePageState(); + State createState() => _HomePageState(); } -class _MyHomePageState extends State { - final textButtonState = GlobalKey(); +class _HomePageState extends State { + final _formController = AsyncButtonController(); + final _externalController = AsyncButtonController(); + final _textController = TextEditingController(); @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: const Text('Async Buttons')), - floatingActionButton: FloatingActionButton.extended( - onPressed: textButtonState.currentState?.pressCallback, - label: const Text('You can trigger first button from here'), - ), - body: SizedBox.expand( - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - const Divider(), - const Text('Text Button:'), - AsyncButtonBuilder( - key: textButtonState, - child: const Text('Click Me'), - onPressed: () async { - await Future.delayed(const Duration(seconds: 1)); - }, - builder: (context, child, callback, _) { - return TextButton(onPressed: callback, child: child); - }, - ), - const Divider(), - const Text('Elevated Button:'), - AsyncButtonBuilder( - child: const Text('Click Me'), - onPressed: () async { - await Future.delayed(const Duration(seconds: 1)); - }, - builder: (context, child, callback, _) { - return ElevatedButton(onPressed: callback, child: child); - }, - ), - const Divider(), - const Text('Custom Outlined Button (Error):'), - AsyncButtonBuilder( - loadingWidget: const Text('Loading...'), - onPressed: () async { - await Future.delayed(const Duration(seconds: 1)); - throw 'yikes'; - }, - builder: (context, child, callback, buttonState) { - final buttonColor = switch (buttonState) { - AsyncButtonStateIdle() => Colors.yellow[200], - AsyncButtonStateLoading() => Colors.grey, - AsyncButtonStateSuccess() => Colors.orangeAccent, - AsyncButtonStateError() => Colors.orange, - }; + void dispose() { + _formController.dispose(); + _externalController.dispose(); + _textController.dispose(); + super.dispose(); + } - return OutlinedButton( - onPressed: callback, - style: OutlinedButton.styleFrom( - foregroundColor: Colors.black, - backgroundColor: buttonColor, - ), - child: child, - ); - }, - child: const Text('Click Me'), - ), - const Divider(), - const Text('Custom Material Button:'), - const SizedBox(height: 6.0), - AsyncButtonBuilder( - loadingWidget: const Padding( - padding: EdgeInsets.all(8.0), - child: SizedBox( - height: 16.0, - width: 16.0, - child: CircularProgressIndicator( - valueColor: AlwaysStoppedAnimation(Colors.white), - ), - ), + Future _simulateWork({bool fail = false}) async { + await Future.delayed(const Duration(milliseconds: 800)); + if (fail) throw StateError('simulated failure'); + } + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: const Text('material_async_button')), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: .stretch, + children: [ + const _SectionLabel('Material wrappers'), + ElevatedAsyncButton( + onPressed: _simulateWork, + child: const Text('ElevatedAsyncButton'), + ), + const SizedBox(height: 8), + ElevatedAsyncButton.icon( + onPressed: _simulateWork, + icon: const Icon(Icons.send), + label: const Text('ElevatedAsyncButton.icon'), + ), + const SizedBox(height: 8), + FilledAsyncButton( + onPressed: _simulateWork, + child: const Text('FilledAsyncButton'), + ), + const SizedBox(height: 8), + FilledAsyncButton.tonal( + onPressed: _simulateWork, + child: const Text('FilledAsyncButton.tonal'), + ), + const SizedBox(height: 8), + FilledAsyncButton.icon( + onPressed: _simulateWork, + icon: const Icon(Icons.save), + label: const Text('FilledAsyncButton.icon'), + ), + const SizedBox(height: 8), + FilledAsyncButton.tonalIcon( + onPressed: _simulateWork, + icon: const Icon(Icons.cloud_upload), + label: const Text('FilledAsyncButton.tonalIcon'), + ), + const SizedBox(height: 8), + OutlinedAsyncButton( + onPressed: () => _simulateWork(fail: true), + child: const Text('OutlinedAsyncButton (fails)'), + ), + const SizedBox(height: 8), + TextAsyncButton( + onPressed: _simulateWork, + child: const Text('TextAsyncButton'), + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: .spaceEvenly, + children: [ + IconAsyncButton( + onPressed: _simulateWork, + icon: const Icon(Icons.refresh), ), - successWidget: const Padding( - padding: EdgeInsets.all(4.0), - child: Icon(Icons.check, color: Colors.purpleAccent), + IconAsyncButton.filled( + onPressed: _simulateWork, + icon: const Icon(Icons.add), ), - onPressed: () async { - await Future.delayed(const Duration(seconds: 2)); - }, - loadingSwitchInCurve: Curves.bounceInOut, - loadingTransitionBuilder: (child, animation) { - return SlideTransition( - position: Tween( - begin: const Offset(0, 1.0), - end: const Offset(0, 0), - ).animate(animation), - child: child, - ); - }, - builder: (context, child, callback, state) { - return Material( - color: switch (state) { - AsyncButtonStateSuccess() => Colors.purple[100], - _ => Colors.blue, - }, - // This prevents the loading indicator showing below the - // button - clipBehavior: Clip.hardEdge, - shape: const StadiumBorder(), - child: InkWell(onTap: callback, child: child), - ); - }, - child: const Padding( - padding: EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), - child: Text('Click Me', style: TextStyle(color: Colors.white)), + IconAsyncButton.filledTonal( + onPressed: _simulateWork, + icon: const Icon(Icons.edit), + ), + IconAsyncButton.outlined( + onPressed: _simulateWork, + icon: const Icon(Icons.delete), + ), + ], + ), + const Divider(height: 32), + const _SectionLabel('Per-button overrides'), + ElevatedAsyncButton( + onPressed: _simulateWork, + successChild: const Text('Saved!'), + successDisplayDuration: const Duration(milliseconds: 1200), + cooldownDuration: const Duration(seconds: 1), + child: const Text('Custom successChild + 1s cooldown'), + ), + const SizedBox(height: 8), + OutlinedAsyncButton( + onPressed: () => _simulateWork(fail: true), + errorChild: const Text('Failed — try again'), + errorDisplayDuration: const Duration(milliseconds: 1200), + onError: (error, _) => debugPrint('error: $error'), + child: const Text('Custom errorChild + onError'), + ), + const Divider(height: 32), + const _SectionLabel('Form "Done" → controller.trigger()'), + TextField( + controller: _textController, + textInputAction: TextInputAction.done, + decoration: const InputDecoration(labelText: 'Name'), + onSubmitted: (_) => _formController.trigger(), + ), + const SizedBox(height: 8), + ElevatedAsyncButton( + controller: _formController, + onPressed: _simulateWork, + child: const Text('Submit'), + ), + const Divider(height: 32), + const _SectionLabel('External controller'), + ElevatedAsyncButton( + controller: _externalController, + onPressed: _simulateWork, + child: const Text('Submit (driven by controller)'), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: [ + OutlinedButton( + onPressed: _externalController.trigger, + child: const Text('trigger()'), ), + OutlinedButton( + onPressed: () => + _externalController.invalidate('server rejected'), + child: const Text('invalidate()'), + ), + OutlinedButton( + onPressed: _externalController.markSuccess, + child: const Text('markSuccess()'), + ), + OutlinedButton( + onPressed: _externalController.reset, + child: const Text('reset()'), + ), + ], + ), + const Divider(height: 32), + const _SectionLabel('Custom button via AsyncButton'), + AsyncButton( + onPressed: _simulateWork, + animateSize: true, + builder: (ctx, child, callback, status) => Material( + color: switch (status) { + AsyncButtonStatusIdle() || + AsyncButtonStatusLoading() => Colors.indigo, + AsyncButtonStatusSuccess() => Colors.green, + AsyncButtonStatusError() => Colors.red, + }, + clipBehavior: .hardEdge, + shape: const StadiumBorder(), + child: InkWell(onTap: callback, child: child), ), - const Divider(), - ], - ), + child: const Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Text('Custom', style: TextStyle(color: Colors.white)), + ), + ), + ], ), - ); - } + ), + ); +} + +class _SectionLabel extends StatelessWidget { + const _SectionLabel(this.text); + + final String text; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Text(text, style: Theme.of(context).textTheme.titleMedium), + ); } diff --git a/example/pubspec.yaml b/example/pubspec.yaml index cf7e795..b76c652 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -1,23 +1,22 @@ name: example -description: A new Flutter project. -publish_to: "none" # Remove this line if you wish to publish to pub.dev +description: Showcase for material_async_button. +publish_to: "none" version: 1.0.0+1 environment: - sdk: ">=3.7.0 <4.0.0" + sdk: ^3.10.0 + flutter: ">=3.0.0" dependencies: flutter: sdk: flutter - cupertino_icons: ^1.0.1 - - async_button_builder: + material_async_button: path: ../ dev_dependencies: + checks: ^0.3.1 flutter_test: sdk: flutter - flutter_lints: ^2.0.0 flutter: uses-material-design: true diff --git a/example/test/widget_test.dart b/example/test/widget_test.dart index 092d222..763fb11 100644 --- a/example/test/widget_test.dart +++ b/example/test/widget_test.dart @@ -1,30 +1,38 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - +import 'package:checks/checks.dart'; +import 'package:example/main.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_async_button/material_async_button.dart'; -import 'package:example/main.dart'; +extension on Subject { + void findsOne() => has((f) => f.evaluate().length, 'matches').equals(1); + void findsNone() => has((f) => f.evaluate(), 'matches').isEmpty(); + void findsMany([int min = 1]) => + has((f) => f.evaluate().length, 'matches').isGreaterOrEqual(min); +} void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. + testWidgets('builds and shows the Material wrappers section', (tester) async { await tester.pumpWidget(const MyApp()); + await tester.pumpAndSettle(); - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); + check(find.text('Material wrappers')).findsOne(); + check(find.byType(ElevatedAsyncButton)).findsMany(); + check(find.byType(FilledAsyncButton)).findsMany(); + }); + + testWidgets('tapping an ElevatedAsyncButton shows the loading spinner', ( + tester, + ) async { + await tester.pumpWidget(const MyApp()); + await tester.pumpAndSettle(); - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); + await tester.tap(find.text('ElevatedAsyncButton')); await tester.pump(); - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); + check(find.byType(CircularProgressIndicator)).findsOne(); + + await tester.pumpAndSettle(); + check(find.byType(CircularProgressIndicator)).findsNone(); }); } diff --git a/lib/async_button_builder.dart b/lib/async_button_builder.dart deleted file mode 100644 index 0ae7c8c..0000000 --- a/lib/async_button_builder.dart +++ /dev/null @@ -1,3 +0,0 @@ -export 'package:async_button_builder/src/async_button_builder.dart'; -export 'package:async_button_builder/src/async_button_notification.dart'; -export 'package:async_button_builder/src/async_button_state.dart'; diff --git a/lib/material_async_button.dart b/lib/material_async_button.dart new file mode 100644 index 0000000..7ff0856 --- /dev/null +++ b/lib/material_async_button.dart @@ -0,0 +1,24 @@ +/// Drop-in async wrappers for Flutter Material buttons. +/// +/// See `ElevatedAsyncButton`, `FilledAsyncButton`, `OutlinedAsyncButton`, +/// `TextAsyncButton`, and `IconAsyncButton` for the named-constructor +/// variants. Use `AsyncButton` when you need a custom non-Material button. +library; + +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; +import 'package:flutter/services.dart'; + +part 'src/async_button.dart'; +part 'src/async_button_controller.dart'; +part 'src/async_button_status.dart'; +part 'src/buttons/async_material_button.dart'; +part 'src/buttons/elevated_async_button.dart'; +part 'src/buttons/filled_async_button.dart'; +part 'src/buttons/icon_async_button.dart'; +part 'src/buttons/outlined_async_button.dart'; +part 'src/buttons/text_async_button.dart'; +part 'src/material_async_button_theme.dart'; diff --git a/lib/src/async_button.dart b/lib/src/async_button.dart new file mode 100644 index 0000000..d9be886 --- /dev/null +++ b/lib/src/async_button.dart @@ -0,0 +1,441 @@ +part of '../material_async_button.dart'; + +/// Signature for [AsyncButton.builder]. +/// +/// `callback` is `null` when the button should appear disabled (already +/// loading, in cooldown, or `onPressed`/`disabled` make it ineligible). +typedef AsyncButtonWidgetBuilder = + Widget Function( + BuildContext context, + Widget child, + AsyncCallback? callback, + AsyncButtonStatus status, + ); + +/// Signature for `onError`. Receives the thrown error plus the captured +/// stack trace. The error is delivered informationally — the button itself +/// only reacts to [AsyncButtonStatus]. +typedef AsyncButtonErrorCallback = + void Function( + Object error, + StackTrace stackTrace, + ); + +/// Low-level async-status shell for arbitrary buttons. +/// +/// Prefer the named Material wrappers ([ElevatedAsyncButton], +/// [FilledAsyncButton], [OutlinedAsyncButton], [TextAsyncButton], +/// [IconAsyncButton]). Reach for [AsyncButton] directly only when you need +/// to render a non-Material button. +/// +/// The builder receives the current [AsyncButtonStatus] — destructure the +/// error variant to render the error inline: +/// +/// ```dart +/// builder: (context, child, callback, status) => switch (status) { +/// AsyncButtonStatusError(:final error) => Text('failed: $error'), +/// _ => MyButton(onTap: callback, child: child), +/// } +/// ``` +/// +/// ```dart +/// AsyncButton( +/// onPressed: () async => doWork(), +/// child: const Text('Go'), +/// builder: (context, child, callback, status) => MyCustomButton( +/// onTap: callback, +/// child: child, +/// ), +/// ) +/// ``` +class AsyncButton extends StatefulWidget { + /// Creates an [AsyncButton]. See the class doc for usage. + const AsyncButton({ + super.key, + required this.child, + required this.onPressed, + required this.builder, + this.controller, + this.onSuccess, + this.onError, + this.onStateChanged, + this.confirmBeforePress, + this.loadingChild, + this.successChild, + this.errorChild, + this.disabled = false, + this.switchDuration, + this.switchReverseDuration, + this.switchCurve, + this.switchInCurve, + this.switchOutCurve, + this.transitionBuilder, + this.successDisplayDuration, + this.errorDisplayDuration, + this.cooldownDuration, + this.animateSize, + this.sizeCurve, + this.sizeAlignment, + this.sizeClipBehavior, + this.hapticOn, + this.announceSemantics, + this.rethrowErrors, + }); + + /// Idle widget. Replaced by [loadingChild] / [successChild] / [errorChild] + /// during the matching [AsyncButtonStatus]. + final Widget child; + + /// Async callback. `null` makes the button appear disabled. + final AsyncCallback? onPressed; + + /// Renders the button chrome. See [AsyncButtonWidgetBuilder]. + final AsyncButtonWidgetBuilder builder; + + /// External controller. When null, the widget creates and owns its own. + final AsyncButtonController? controller; + + /// Called when the button enters [.success]. + final VoidCallback? onSuccess; + + /// Called when the button enters [.error]. + final AsyncButtonErrorCallback? onError; + + /// Fired on every status change. Use [onError] or read [controller] when + /// you also need the error payload. + final ValueChanged? onStateChanged; + + /// Runs before [onPressed]. If it returns `false` the press is cancelled + /// and no status change happens. + final Future Function(BuildContext context)? confirmBeforePress; + + /// Widget shown while loading. Falls back to [AsyncButtonTheme.loadingChild]. + final Widget? loadingChild; + + /// Widget shown briefly after success. Falls back to + /// [AsyncButtonTheme.successChild]. + final Widget? successChild; + + /// Widget shown briefly after error. Falls back to + /// [AsyncButtonTheme.errorChild]. + final Widget? errorChild; + + /// Forces the button to appear disabled regardless of status. + final bool disabled; + + /// Per-widget override of [AsyncButtonTheme.switchDuration]. + final Duration? switchDuration; + + /// Per-widget override of [AsyncButtonTheme.switchReverseDuration]. + final Duration? switchReverseDuration; + + /// Per-widget override of [AsyncButtonTheme.switchCurve]. + final Curve? switchCurve; + + /// Per-widget override of [AsyncButtonTheme.switchInCurve]. + final Curve? switchInCurve; + + /// Per-widget override of [AsyncButtonTheme.switchOutCurve]. + final Curve? switchOutCurve; + + /// Per-widget override of [AsyncButtonTheme.transitionBuilder]. + final AnimatedSwitcherTransitionBuilder? transitionBuilder; + + /// Per-widget override of [AsyncButtonTheme.successDisplayDuration]. + final Duration? successDisplayDuration; + + /// Per-widget override of [AsyncButtonTheme.errorDisplayDuration]. + final Duration? errorDisplayDuration; + + /// Per-widget override of [AsyncButtonTheme.cooldownDuration]. + final Duration? cooldownDuration; + + /// Per-widget override of [AsyncButtonTheme.animateSize]. + final bool? animateSize; + + /// Per-widget override of [AsyncButtonTheme.sizeCurve]. + final Curve? sizeCurve; + + /// Per-widget override of [AsyncButtonTheme.sizeAlignment]. + final AlignmentGeometry? sizeAlignment; + + /// Per-widget override of [AsyncButtonTheme.sizeClipBehavior]. + final Clip? sizeClipBehavior; + + /// Per-widget override of [AsyncButtonTheme.hapticOn]. + final HapticOn? hapticOn; + + /// Per-widget override of [AsyncButtonTheme.announceSemantics]. + final bool? announceSemantics; + + /// Per-widget override of [AsyncButtonTheme.rethrowErrors]. + final bool? rethrowErrors; + + @override + State createState() { + return _AsyncButtonState(); + } +} + +class _AsyncButtonState extends State { + AsyncButtonController? _internalController; + AsyncButtonController get _controller => + widget.controller ?? _internalController!; + + AsyncButtonStatus _lastStatus = const .idle(); + + @override + void initState() { + super.initState(); + if (widget.controller == null) { + _internalController = AsyncButtonController(); + } + _controller.addListener(_handleControllerChange); + _lastStatus = _controller.value; + } + + @override + void didUpdateWidget(covariant AsyncButton oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.controller == oldWidget.controller) { + return; + } + final previous = oldWidget.controller ?? _internalController; + previous?.removeListener(_handleControllerChange); + if (widget.controller != null) { + _internalController?.dispose(); + _internalController = null; + } else { + _internalController = AsyncButtonController(); + } + _controller.addListener(_handleControllerChange); + _lastStatus = _controller.value; + } + + @override + void dispose() { + _controller.removeListener(_handleControllerChange); + _internalController?.dispose(); + super.dispose(); + } + + void _handleControllerChange() { + final newStatus = _controller.value; + if (newStatus != _lastStatus) { + widget.onStateChanged?.call(newStatus); + _fireHaptic(newStatus); + _announceSemantics(newStatus); + if (newStatus is AsyncButtonStatusSuccess && + _lastStatus is! AsyncButtonStatusSuccess) { + widget.onSuccess?.call(); + } else if (newStatus is AsyncButtonStatusError && + _lastStatus is! AsyncButtonStatusError) { + widget.onError?.call( + newStatus.error, + newStatus.stackTrace ?? StackTrace.empty, + ); + } + _lastStatus = newStatus; + } + if (mounted) { + setState(() {}); + } + } + + void _fireHaptic(AsyncButtonStatus to) { + final mode = + widget.hapticOn ?? AsyncButtonTheme.of(context).hapticOn ?? .none; + if (mode == .none) { + return; + } + final wantSuccess = mode == .success || mode == .both; + final wantError = mode == .error || mode == .both; + if (to is AsyncButtonStatusSuccess && wantSuccess) { + unawaited(HapticFeedback.lightImpact()); + } else if (to is AsyncButtonStatusError && wantError) { + unawaited(HapticFeedback.mediumImpact()); + } + } + + void _announceSemantics(AsyncButtonStatus status) { + final on = + widget.announceSemantics ?? + AsyncButtonTheme.of(context).announceSemantics ?? + false; + if (!on) { + return; + } + final message = switch (status) { + AsyncButtonStatusIdle() => null, + AsyncButtonStatusLoading() => 'Loading', + AsyncButtonStatusSuccess() => 'Success', + AsyncButtonStatusError() => 'Error', + }; + if (message == null) { + return; + } + final view = View.maybeOf(context); + if (view == null) { + return; + } + final direction = Directionality.maybeOf(context) ?? .ltr; + unawaited(SemanticsService.sendAnnouncement(view, message, direction)); + } + + @override + Widget build(BuildContext context) { + final config = _resolveConfig(AsyncButtonTheme.of(context)); + + _controller.attach( + onPressed: _gatedOnPressed(), + successDuration: config.successDisplayDuration, + errorDuration: config.errorDisplayDuration, + cooldownDuration: config.cooldownDuration, + rethrowErrors: config.rethrowErrors, + ); + + final status = _controller.value; + final visible = switch (status) { + AsyncButtonStatusIdle() => widget.child, + AsyncButtonStatusLoading() => config.loadingChild, + AsyncButtonStatusSuccess() => config.successChild ?? widget.child, + AsyncButtonStatusError() => config.errorChild ?? widget.child, + }; + + Widget content = AnimatedSwitcher( + duration: config.switchDuration, + reverseDuration: config.switchReverseDuration, + switchInCurve: config.switchInCurve, + switchOutCurve: config.switchOutCurve, + transitionBuilder: config.transitionBuilder, + child: KeyedSubtree( + key: ValueKey(status), + child: visible, + ), + ); + + if (config.animateSize) { + content = AnimatedSize( + duration: config.switchDuration, + reverseDuration: config.switchReverseDuration, + alignment: config.sizeAlignment, + clipBehavior: config.sizeClipBehavior, + curve: config.sizeCurve, + child: content, + ); + } + + return widget.builder(context, content, _builderCallback(), status); + } + + /// Callback passed back through the builder. Null when the button is in a + /// status that should appear disabled. + AsyncCallback? _builderCallback() { + if (widget.disabled || widget.onPressed == null) { + return null; + } + if (!_controller.canTrigger) { + return null; + } + return _controller.trigger; + } + + /// The `onPressed` that `controller.trigger` will actually run, wrapped + /// with `confirmBeforePress` and only invoked when not disabled. + AsyncCallback? _gatedOnPressed() { + if (widget.disabled || widget.onPressed == null) { + return null; + } + final confirm = widget.confirmBeforePress; + final raw = widget.onPressed!; + if (confirm == null) { + return raw; + } + return () async { + if (!mounted) { + return; + } + final ok = await confirm(context); + if (!mounted || !ok) { + return; + } + await raw(); + }; + } + + _ResolvedConfig _resolveConfig(AsyncButtonTheme t) { + final w = widget; + final fallbackCurve = w.switchCurve ?? t.switchCurve ?? Curves.linear; + return _ResolvedConfig( + loadingChild: w.loadingChild ?? t.loadingChild ?? _defaultLoadingChild, + successChild: w.successChild ?? t.successChild, + errorChild: w.errorChild ?? t.errorChild, + switchDuration: + w.switchDuration ?? + t.switchDuration ?? + const Duration(milliseconds: 200), + switchReverseDuration: w.switchReverseDuration ?? t.switchReverseDuration, + switchInCurve: w.switchInCurve ?? t.switchInCurve ?? fallbackCurve, + switchOutCurve: w.switchOutCurve ?? t.switchOutCurve ?? fallbackCurve, + transitionBuilder: + w.transitionBuilder ?? + t.transitionBuilder ?? + AnimatedSwitcher.defaultTransitionBuilder, + successDisplayDuration: + w.successDisplayDuration ?? t.successDisplayDuration ?? .zero, + errorDisplayDuration: + w.errorDisplayDuration ?? t.errorDisplayDuration ?? .zero, + cooldownDuration: w.cooldownDuration ?? t.cooldownDuration ?? .zero, + animateSize: w.animateSize ?? t.animateSize ?? false, + sizeCurve: w.sizeCurve ?? t.sizeCurve ?? Curves.linear, + sizeAlignment: w.sizeAlignment ?? t.sizeAlignment ?? .center, + sizeClipBehavior: w.sizeClipBehavior ?? t.sizeClipBehavior ?? .hardEdge, + rethrowErrors: w.rethrowErrors ?? t.rethrowErrors ?? false, + ); + } +} + +/// Per-build resolution of widget props ▶ theme ▶ defaults. +@immutable +class _ResolvedConfig { + const _ResolvedConfig({ + required this.loadingChild, + required this.successChild, + required this.errorChild, + required this.switchDuration, + required this.switchReverseDuration, + required this.switchInCurve, + required this.switchOutCurve, + required this.transitionBuilder, + required this.successDisplayDuration, + required this.errorDisplayDuration, + required this.cooldownDuration, + required this.animateSize, + required this.sizeCurve, + required this.sizeAlignment, + required this.sizeClipBehavior, + required this.rethrowErrors, + }); + + final Widget loadingChild; + final Widget? successChild; + final Widget? errorChild; + final Duration switchDuration; + final Duration? switchReverseDuration; + final Curve switchInCurve; + final Curve switchOutCurve; + final AnimatedSwitcherTransitionBuilder transitionBuilder; + final Duration successDisplayDuration; + final Duration errorDisplayDuration; + final Duration cooldownDuration; + final bool animateSize; + final Curve sizeCurve; + final AlignmentGeometry sizeAlignment; + final Clip sizeClipBehavior; + final bool rethrowErrors; +} + +const _defaultLoadingChild = SizedBox.square( + dimension: 16, + child: CircularProgressIndicator(strokeWidth: 2), +); diff --git a/lib/src/async_button_builder.dart b/lib/src/async_button_builder.dart deleted file mode 100644 index d2562e1..0000000 --- a/lib/src/async_button_builder.dart +++ /dev/null @@ -1,411 +0,0 @@ -import 'dart:async'; - -import 'package:async_button_builder/async_button_builder.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; - -typedef AsyncButtonBuilderCallback = Widget Function( - BuildContext context, - Widget child, - AsyncCallback? callback, - AsyncButtonState buttonState, -); - -/// A `builder` that wraps a button providing disabled, loading, success and -/// error states while retaining almost full access to the original Button's -/// API. This is useful for any long running operations and helps better -/// improve UX. -/// -/// {@tool dartpad --template=stateful_widget_material} -/// -/// ```dart -/// -/// @override -/// Widget build(BuildContext context) { -/// return AsyncButtonBuilder( -/// child: Text('Click Me'), -/// loadingWidget: Text('Loading...'), -/// onPressed: () async { -/// await Future.delayed(Duration(seconds: 1)); -/// -/// throw 'yikes'; -/// }, -/// builder: (context, child, callback, buttonState) { -/// final buttonColor = buttonState.when( -/// idle: () => Colors.yellow[200], -/// loading: () => Colors.grey, -/// success: () => Colors.orangeAccent, -/// error: () => Colors.orange, -/// ); -/// -/// return OutlinedButton( -/// child: child, -/// onPressed: callback, -/// style: OutlinedButton.styleFrom( -/// primary: Colors.black, -/// backgroundColor: buttonColor, -/// ), -/// ); -/// }, -/// ), -/// } -/// ``` -/// {@end-tool} -class AsyncButtonBuilder extends StatefulWidget { - const AsyncButtonBuilder({ - super.key, - required this.child, - required this.onPressed, - required this.builder, - this.onSuccess, - this.onError, - this.loadingWidget = const SizedBox.square( - dimension: 16.0, - child: CircularProgressIndicator(), - ), - this.successWidget, - this.errorWidget, - this.showSuccess = true, - this.showError = true, - this.errorPadding, - this.successPadding, - this.buttonState = const AsyncButtonState.idle(), - this.duration = const Duration(milliseconds: 250), - this.reverseDuration = const Duration(milliseconds: 200), - this.disabled = false, - this.successDuration = const Duration(seconds: 1), - this.errorDuration = const Duration(seconds: 1), - this.loadingTransitionBuilder = AnimatedSwitcher.defaultTransitionBuilder, - this.idleTransitionBuilder = AnimatedSwitcher.defaultTransitionBuilder, - this.successTransitionBuilder = AnimatedSwitcher.defaultTransitionBuilder, - this.errorTransitionBuilder = AnimatedSwitcher.defaultTransitionBuilder, - this.idleSwitchInCurve = Curves.linear, - this.loadingSwitchInCurve = Curves.linear, - this.successSwitchInCurve = Curves.linear, - this.errorSwitchInCurve = Curves.linear, - this.idleSwitchOutCurve = Curves.linear, - this.loadingSwitchOutCurve = Curves.linear, - this.successSwitchOutCurve = Curves.linear, - this.errorSwitchOutCurve = Curves.linear, - this.sizeCurve = Curves.linear, - this.sizeClipBehavior = Clip.hardEdge, - this.sizeAlignment = Alignment.center, - this.animateSize = true, - this.notifications = false, - }); - - /// This builder provides the widget's [BuildContext], the variable [child] - /// based on button state as well as the [callback] that should be passed to - /// the button and the current [ButtonState] - final AsyncButtonBuilderCallback builder; - - /// The child of the button. In the case of an [IconButton], this can be a an - /// [Icon]. For a [TextButton], a [Text]. - /// - /// This child will be animated between for the [loadingWidget] or default - /// [CircularProgressIndicator] when the asynchronous [onPressed] is called. - /// The animation will take place over [duration]. - final Widget child; - - /// The animation's duration between [child], [loadingWidget], - /// [successWidget] and [errorWidget]. This same value is used for both the - /// internal [AnimatedSize] and [TransitionBuilder]. - final Duration duration; - - /// The animation's reverse duration between [child], [loadingWidget], - /// [successWidget] and [errorWidget]. This same value is used for both the - /// internal [AnimatedSize] and [TransitionBuilder]. - final Duration reverseDuration; - - /// A callback that runs the async task. This is wrapped in order to begin - /// the button's internal `isLoading` before and after the operation - /// completes. - final AsyncCallback? onPressed; - - /// A callback that runs [buttonState] changes to [AsyncButtonState.success] - final VoidCallback? onSuccess; - - /// A callback that runs [buttonState] changes to [AsyncButtonState.error] - final VoidCallback? onError; - - /// This is used to manually drive the state of the loading button thus - /// initiating the corresponding animation and showing the correct button - /// child. - final AsyncButtonState buttonState; - - /// This is used to manually drive the disabled state of the button. - final bool disabled; - - /// The widget replaces the [child] when the button is in the loading state. - /// If this is null the default widget is: - /// - /// SizedBox( - /// height: 16.0, - /// width: 16.0, - /// child: CircularProgressIndicator(), - /// ) - final Widget loadingWidget; - - /// The widget used to replace the [child] when the button is in a success - /// state. If this is null the default widget is: - /// - /// Icon( - /// Icons.check, - /// color: Theme.of(context).accentColor, - /// ); - final Widget? successWidget; - - /// The widget used to replace the [child] when the button is in a error - /// state. If this is null the default widget is: - /// - /// Icon( - /// Icons.error, - /// color: Theme.of(context).errorColor, - /// ) - final Widget? errorWidget; - - /// Whether to show the [successWidget] on success. - final bool showSuccess; - - /// Whether to show the [errorWidget] on error. - final bool showError; - - /// Optional [EdgeInsets] that will wrap around the [errorWidget]. This is a - /// convenience field that can be replaced by defining your own [errorWidget] - /// and wrapping it in a [Padding]. - final EdgeInsets? errorPadding; - - /// Optional [EdgeInsets] that will wrap around the [successWidget]. This is a - /// convenience field that can be replaced by defining your own - /// [successWidget] and wrapping it in a [Padding]. - final EdgeInsets? successPadding; - - /// Defines a custom transition when animating between any state and `idle` - final AnimatedSwitcherTransitionBuilder idleTransitionBuilder; - - /// Defines a custom transition when animating between any state and `loading` - final AnimatedSwitcherTransitionBuilder loadingTransitionBuilder; - - /// Defines a custom transition when animating between any state and `success` - final AnimatedSwitcherTransitionBuilder successTransitionBuilder; - - /// Defines a custom transition when animating between any state and `error` - final AnimatedSwitcherTransitionBuilder errorTransitionBuilder; - - /// The amount of idle time the [successWidget] shows - final Duration successDuration; - - /// The amount of idle time the [errorWidget] shows - final Duration errorDuration; - - /// Defines a curve for the custom transition. This used in in an - /// [AnimatedSwitcher] and only takes effect when animating to `idle` - final Curve idleSwitchInCurve; - - /// Defines a curve for the custom transition. This used in in an - /// [AnimatedSwitcher] and only takes effect when animating to `loading` - final Curve loadingSwitchInCurve; - - /// Defines a curve for the custom transition. This used in in an - /// [AnimatedSwitcher] and only takes effect when animating to `success` - final Curve successSwitchInCurve; - - /// Defines a curve for the custom transition. This used in in an - /// [AnimatedSwitcher] and only takes effect when animating to `error` - final Curve errorSwitchInCurve; - - /// Defines a curve for the custom transition. This used in in an - /// [AnimatedSwitcher] and only takes effect when animating out of `idle` - final Curve idleSwitchOutCurve; - - /// Defines a curve for the custom transition. This used in in an - /// [AnimatedSwitcher] and only takes effect when animating out of `loading` - final Curve loadingSwitchOutCurve; - - /// Defines a curve for the custom transition. This used in in an - /// [AnimatedSwitcher] and only takes effect when animating out of `success` - final Curve successSwitchOutCurve; - - /// Defines a curve for the custom transition. This used in in an - /// [AnimatedSwitcher] and only takes effect when animating out of `error` - final Curve errorSwitchOutCurve; - - /// Defines a curve for the internal [AnimatedSize] - final Curve sizeCurve; - - /// Defines the [Clip] for the internal [AnimatedSize] - final Clip sizeClipBehavior; - - /// Defines the [Alignment] for the internal [AnimatedSize] - final Alignment sizeAlignment; - - /// Whether to animate the [Size] of the widget implicitly. - final bool animateSize; - - /// Whether we should bubble up [AsyncButtonNotification]s up the widget tree. - /// This is useful if you want to listen to the state of the button from a - /// parent widget. - final bool notifications; - - @override - State createState() => AsyncButtonBuilderState(); -} - -class AsyncButtonBuilderState extends State - with SingleTickerProviderStateMixin { - late AsyncButtonState _buttonState = widget.buttonState; - Key _switchKey = UniqueKey(); - Timer? timer; - - @override - void didUpdateWidget(covariant AsyncButtonBuilder oldWidget) { - if (widget.buttonState != oldWidget.buttonState) { - _setButtonState(widget.buttonState); - } - super.didUpdateWidget(oldWidget); - } - - @override - void dispose() { - timer?.cancel(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - Widget successWidget = widget.successWidget ?? - Icon(Icons.check, color: theme.colorScheme.secondary); - Widget errorWidget = - widget.errorWidget ?? Icon(Icons.error, color: theme.colorScheme.error); - if (widget.successPadding != null) { - successWidget = Padding( - padding: widget.successPadding!, - child: successWidget, - ); - } - - if (widget.errorPadding != null) { - errorWidget = Padding( - padding: widget.errorPadding!, - child: errorWidget, - ); - } - - Widget content = AnimatedSwitcher( - // TODO: This duration is same as size's duration. That's okay right? - duration: widget.duration, - reverseDuration: widget.reverseDuration, - switchInCurve: switch (_buttonState) { - AsyncButtonStateIdle() => widget.idleSwitchInCurve, - AsyncButtonStateLoading() => widget.loadingSwitchInCurve, - AsyncButtonStateSuccess() => widget.successSwitchInCurve, - AsyncButtonStateError() => widget.errorSwitchInCurve, - }, - switchOutCurve: switch (_buttonState) { - AsyncButtonStateIdle() => widget.idleSwitchOutCurve, - AsyncButtonStateLoading() => widget.loadingSwitchOutCurve, - AsyncButtonStateSuccess() => widget.successSwitchOutCurve, - AsyncButtonStateError() => widget.errorSwitchOutCurve, - }, - transitionBuilder: switch (_buttonState) { - AsyncButtonStateIdle() => widget.idleTransitionBuilder, - AsyncButtonStateLoading() => widget.loadingTransitionBuilder, - AsyncButtonStateSuccess() => widget.successTransitionBuilder, - AsyncButtonStateError() => widget.errorTransitionBuilder, - }, - child: KeyedSubtree( - key: _switchKey, - child: switch (_buttonState) { - AsyncButtonStateIdle() => widget.child, - AsyncButtonStateLoading() => widget.loadingWidget, - AsyncButtonStateSuccess() => successWidget, - AsyncButtonStateError() => errorWidget, - }, - ), - ); - - if (widget.animateSize) { - // TODO: I really just wanted an AnimatedSwitcher and the default - // transitionBuilder to be a SizedTransition but it was impossible - // to figure out how to reproduce the exact behaviour of AnimatedSize - content = AnimatedSize( - duration: widget.duration, - reverseDuration: widget.reverseDuration, - alignment: widget.sizeAlignment, - clipBehavior: widget.sizeClipBehavior, - curve: widget.sizeCurve, - child: content, - ); - } - - return widget.builder(context, content, pressCallback, _buttonState); - } - - AsyncCallback? get pressCallback { - if (widget.disabled || widget.onPressed == null) { - return null; - } - return switch (_buttonState) { - AsyncButtonStateIdle() => () { - final completer = Completer(); - - // I might not want to set buttonState if we're being - // driven by widget.buttonState... - _setButtonState(const AsyncButtonState.loading()); - timer?.cancel(); - - widget.onPressed!().then((_) { - completer.complete(); - - if (mounted) { - if (widget.showSuccess) { - _setButtonState(const AsyncButtonState.success()); - _setTimer(widget.successDuration, widget.onSuccess); - } else { - _setButtonState(const AsyncButtonState.idle()); - } - } - }).onError((Object error, StackTrace stackTrace) { - completer.completeError(error, stackTrace); - - if (mounted) { - if (widget.showError) { - _setButtonState(AsyncButtonState.error(error)); - _setTimer(widget.errorDuration, widget.onError); - } else { - _setButtonState(const AsyncButtonState.idle()); - } - } - }); - - return completer.future; - }, - _ => null, - }; - } - - void _setButtonState(AsyncButtonState buttonState) { - setState(() { - _switchKey = UniqueKey(); - _buttonState = buttonState; - }); - - if (widget.notifications) { - AsyncButtonNotification(buttonState: buttonState).dispatch(context); - } - } - - void _setTimer(Duration duration, [VoidCallback? then]) { - timer = Timer( - duration, - () { - timer?.cancel(); - then?.call(); - if (mounted) { - _setButtonState(const AsyncButtonState.idle()); - } - }, - ); - } -} diff --git a/lib/src/async_button_controller.dart b/lib/src/async_button_controller.dart new file mode 100644 index 0000000..4068496 --- /dev/null +++ b/lib/src/async_button_controller.dart @@ -0,0 +1,164 @@ +part of '../material_async_button.dart'; + +/// [ValueNotifier] of [AsyncButtonStatus] for an [AsyncButton] or any of +/// the Material wrappers. Pipe it directly into a +/// `ValueListenableBuilder` for reactive UI outside the +/// button — pattern-match the [value] for the error payload on error. +/// +/// Use it to: +/// - trigger the attached `onPressed` from outside the button +/// (e.g. a form keyboard "Done" action), +/// - reset to idle, +/// - mark the button as failed from an out-of-band source +/// (e.g. a WebSocket message) via [invalidate], +/// - mark the button as succeeded from outside via [markSuccess]. +/// +/// Prefer [reset], [invalidate], [markSuccess], or [trigger] over assigning +/// to [value] directly — the imperative methods keep the display-duration +/// timers and cooldown machinery coherent. +/// +/// Dispose like any [ChangeNotifier]. +class AsyncButtonController extends ValueNotifier { + /// Creates a controller with an optional initial status (defaults to idle). + AsyncButtonController([super.initial = const .idle()]); + + /// True when [value] is [AsyncButtonStatusIdle]. + bool get isIdle => value is AsyncButtonStatusIdle; + + /// True when [value] is [AsyncButtonStatusLoading]. + bool get isLoading => value is AsyncButtonStatusLoading; + + /// True when [value] is [AsyncButtonStatusSuccess]. + bool get isSuccess => value is AsyncButtonStatusSuccess; + + /// True when [value] is [AsyncButtonStatusError]. + bool get isError => value is AsyncButtonStatusError; + + /// True while the post-success/error cooldown is blocking [trigger]. + @visibleForTesting + bool get isInCooldown => _cooldownActive; + + /// True when [trigger] would actually run the attached callback. + bool get canTrigger => isIdle && !_cooldownActive && _onPressed != null; + + // Widget-owned configuration. Refreshed on every build. + AsyncCallback? _onPressed; + Duration _successDuration = .zero; + Duration _errorDuration = .zero; + Duration _cooldownDuration = .zero; + bool _rethrowErrors = false; + + Timer? _timer; + bool _cooldownActive = false; + bool _disposed = false; + + /// Internal hook used by [AsyncButton] to push the host widget's current + /// configuration into the controller on every build. Calling this from + /// outside the package is supported (tests drive a detached controller + /// this way), but the values you set will be overwritten on the next + /// build if the controller is also bound to an [AsyncButton]. + void attach({ + required AsyncCallback? onPressed, + required Duration successDuration, + required Duration errorDuration, + required Duration cooldownDuration, + required bool rethrowErrors, + }) { + _onPressed = onPressed; + _successDuration = successDuration; + _errorDuration = errorDuration; + _cooldownDuration = cooldownDuration; + _rethrowErrors = rethrowErrors; + } + + /// Run the attached `onPressed`. No-op if already loading, in cooldown, + /// or if no callback is attached. + Future trigger() async { + if (!canTrigger) { + return; + } + _cancelTimer(); + value = const .loading(); + try { + await _onPressed!(); + // External resets (e.g. controller.reset()) may have moved us off + // loading mid-await. Only continue the success cycle if we're still + // the one driving. + if (!_disposed && value is AsyncButtonStatusLoading) { + value = const .success(); + _scheduleReturnToIdle(_successDuration); + } + } catch (error, stack) { + if (!_disposed && value is AsyncButtonStatusLoading) { + value = .error(error, stack); + _scheduleReturnToIdle(_errorDuration); + } + if (_rethrowErrors) { + rethrow; + } + } + } + + /// Force the button back to idle. Cancels any pending display/cooldown. + void reset() { + _cancelTimer(); + _cooldownActive = false; + value = const .idle(); + } + + /// Force the error status from outside. Runs the same display cycle as + /// if `onPressed` had thrown. + void invalidate(Object error, [StackTrace? stackTrace]) { + _cancelTimer(); + value = .error(error, stackTrace); + _scheduleReturnToIdle(_errorDuration); + } + + /// Force the success status from outside. Runs the same display cycle as + /// a completed `onPressed`. + void markSuccess() { + _cancelTimer(); + value = const .success(); + _scheduleReturnToIdle(_successDuration); + } + + void _cancelTimer() { + _timer?.cancel(); + _timer = null; + } + + void _scheduleReturnToIdle(Duration displayDuration) { + if (displayDuration <= .zero) { + _enterIdleThenCooldown(); + return; + } + _timer = Timer(displayDuration, _enterIdleThenCooldown); + } + + void _enterIdleThenCooldown() { + if (_disposed) { + return; + } + _timer = null; + value = const .idle(); + if (_cooldownDuration > .zero) { + _cooldownActive = true; + notifyListeners(); + _timer = Timer(_cooldownDuration, () { + if (_disposed) { + return; + } + _timer = null; + _cooldownActive = false; + notifyListeners(); + }); + } + } + + @override + void dispose() { + _disposed = true; + _cancelTimer(); + super.dispose(); + } +} diff --git a/lib/src/async_button_notification.dart b/lib/src/async_button_notification.dart deleted file mode 100644 index d418060..0000000 --- a/lib/src/async_button_notification.dart +++ /dev/null @@ -1,8 +0,0 @@ -import 'package:async_button_builder/async_button_builder.dart'; -import 'package:flutter/material.dart'; - -class AsyncButtonNotification extends Notification { - const AsyncButtonNotification({required this.buttonState}); - - final AsyncButtonState buttonState; -} diff --git a/lib/src/async_button_state.dart b/lib/src/async_button_state.dart deleted file mode 100644 index a4aa2a7..0000000 --- a/lib/src/async_button_state.dart +++ /dev/null @@ -1,74 +0,0 @@ -/// This union class represents the state of the button in either a [Idling], -/// [AsyncButtonStateLoading], [AsyncButtonStateSuccess] or [AsyncButtonStateError] state. -/// This can be considered a enum with extra utilities for ease of use. -/// -/// {@tool snippet} -/// -/// ```dart -/// final buttonColor = AsyncbuttonState.when( -/// idle: () => Colors.pink, -/// loading: () => Colors.blue, -/// success: () => Colors.green, -/// error: () => Colors.red, -/// ); -/// ``` -/// {@end-tool} -/// -/// You can also disregard other states and handle only those you'd like using -/// the `.maybeWhen` syntax. -/// -/// {@tool snippet} -/// -/// ```dart -/// final buttonColor = AsyncbuttonState.maybeWhen( -/// idle: () => Colors.pink, -/// orElse: () => Colors.red, -/// ); -/// ``` -/// {@end-tool} -sealed class AsyncButtonState { - const AsyncButtonState(); - - const factory AsyncButtonState.idle() = AsyncButtonStateIdle; - const factory AsyncButtonState.loading() = AsyncButtonStateLoading; - const factory AsyncButtonState.success() = AsyncButtonStateSuccess; - const factory AsyncButtonState.error(Object error) = AsyncButtonStateError; -} - -class AsyncButtonStateIdle extends AsyncButtonState { - const AsyncButtonStateIdle(); - - @override - String toString() { - return 'AsyncButtonState.idle()'; - } -} - -class AsyncButtonStateLoading extends AsyncButtonState { - const AsyncButtonStateLoading(); - - @override - String toString() { - return 'AsyncButtonState.loading()'; - } -} - -class AsyncButtonStateSuccess extends AsyncButtonState { - const AsyncButtonStateSuccess(); - - @override - String toString() { - return 'AsyncButtonState.success()'; - } -} - -class AsyncButtonStateError extends AsyncButtonState { - const AsyncButtonStateError(this.error); - - final Object error; - - @override - String toString() { - return 'AsyncButtonState.error(error: $error)'; - } -} diff --git a/lib/src/async_button_status.dart b/lib/src/async_button_status.dart new file mode 100644 index 0000000..2a4bf28 --- /dev/null +++ b/lib/src/async_button_status.dart @@ -0,0 +1,107 @@ +part of '../material_async_button.dart'; + +/// Status of an async button. Sealed — pattern-match exhaustively, and pull +/// the [AsyncButtonStatusError.error] / [AsyncButtonStatusError.stackTrace] +/// payload directly from the error variant. +/// +/// ```dart +/// final color = switch (status) { +/// AsyncButtonStatusIdle() => Colors.blue, +/// AsyncButtonStatusLoading() => Colors.grey, +/// AsyncButtonStatusSuccess() => Colors.green, +/// AsyncButtonStatusError() => Colors.red, +/// }; +/// ``` +/// +/// The three singleton variants rely on `const` canonicalisation for +/// equality — always construct them via `const` or the factory ctors. +@immutable +sealed class AsyncButtonStatus { + const AsyncButtonStatus(); + + const factory AsyncButtonStatus.idle() = AsyncButtonStatusIdle; + const factory AsyncButtonStatus.loading() = AsyncButtonStatusLoading; + const factory AsyncButtonStatus.success() = AsyncButtonStatusSuccess; + const factory AsyncButtonStatus.error( + Object error, [ + StackTrace? stackTrace, + ]) = AsyncButtonStatusError; +} + +/// Idle variant of [AsyncButtonStatus] — no async work in flight. +final class AsyncButtonStatusIdle extends AsyncButtonStatus { + /// Const constructor; prefer the [AsyncButtonStatus.idle] factory. + const AsyncButtonStatusIdle(); + + @override + String toString() { + return '.idle()'; + } +} + +/// Loading variant of [AsyncButtonStatus] — `onPressed` is in flight. +final class AsyncButtonStatusLoading extends AsyncButtonStatus { + /// Const constructor; prefer the [AsyncButtonStatus.loading] factory. + const AsyncButtonStatusLoading(); + + @override + String toString() { + return '.loading()'; + } +} + +/// Success variant of [AsyncButtonStatus] — `onPressed` completed. +final class AsyncButtonStatusSuccess extends AsyncButtonStatus { + /// Const constructor; prefer the [AsyncButtonStatus.success] factory. + const AsyncButtonStatusSuccess(); + + @override + String toString() { + return '.success()'; + } +} + +/// Error variant of [AsyncButtonStatus]. Produced when `onPressed` throws or +/// [AsyncButtonController.invalidate] is called. +final class AsyncButtonStatusError extends AsyncButtonStatus { + /// Const constructor; prefer the [AsyncButtonStatus.error] factory. + const AsyncButtonStatusError(this.error, [this.stackTrace]); + + /// The thrown error. + final Object error; + + /// Captured stack trace, when available. + final StackTrace? stackTrace; + + /// Equality compares [error] by runtime type and `toString()` payload — + /// most thrown errors (Exceptions, Errors, custom error objects) do not + /// override `operator ==`, so a raw `error == other.error` collapses to + /// identity. Two `invalidate('bad')` calls or two `StateError('oops')` + /// throws are treated as the same error for listener-dedup purposes. + /// [stackTrace] is informational and excluded from equality. + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other is! AsyncButtonStatusError) { + return false; + } + return error.runtimeType == other.error.runtimeType && + error.toString() == other.error.toString(); + } + + @override + int get hashCode { + return Object.hash( + AsyncButtonStatusError, + error.runtimeType, + error.toString(), + ); + } + + @override + String toString() { + return '.error($error)'; + } +} diff --git a/lib/src/buttons/async_material_button.dart b/lib/src/buttons/async_material_button.dart new file mode 100644 index 0000000..7cd536c --- /dev/null +++ b/lib/src/buttons/async_material_button.dart @@ -0,0 +1,171 @@ +part of '../../material_async_button.dart'; + +/// Abstract base for the Material wrapper widgets shipped with this package +/// ([ElevatedAsyncButton], [FilledAsyncButton], [OutlinedAsyncButton], +/// [TextAsyncButton], [IconAsyncButton]). +/// +/// Owns the shared async-status surface — [onPressed], [controller], and +/// the theme-override knobs. Subclasses implement [build] and forward these +/// fields to an [AsyncButton]. For custom non-Material buttons reach for +/// [AsyncButton] directly. +abstract class AsyncMaterialButton extends StatelessWidget { + /// Subclass-only constructor. Forwards every field to [AsyncButton]. See + /// [AsyncButton] for the semantics of each parameter. + const AsyncMaterialButton({ + super.key, + required this.child, + required this.onPressed, + this.controller, + this.onSuccess, + this.onError, + this.onStateChanged, + this.confirmBeforePress, + this.loadingChild, + this.successChild, + this.errorChild, + this.disabled = false, + this.switchDuration, + this.transitionBuilder, + this.successDisplayDuration, + this.errorDisplayDuration, + this.cooldownDuration, + this.animateSize, + this.hapticOn, + this.announceSemantics, + this.rethrowErrors, + }); + + /// See [AsyncButton.child]. + final Widget child; + + /// See [AsyncButton.onPressed]. + final AsyncCallback? onPressed; + + /// See [AsyncButton.controller]. + final AsyncButtonController? controller; + + /// See [AsyncButton.onSuccess]. + final VoidCallback? onSuccess; + + /// See [AsyncButton.onError]. + final AsyncButtonErrorCallback? onError; + + /// See [AsyncButton.onStateChanged]. + final ValueChanged? onStateChanged; + + /// See [AsyncButton.confirmBeforePress]. + final Future Function(BuildContext context)? confirmBeforePress; + + /// See [AsyncButton.loadingChild]. + final Widget? loadingChild; + + /// See [AsyncButton.successChild]. + final Widget? successChild; + + /// See [AsyncButton.errorChild]. + final Widget? errorChild; + + /// See [AsyncButton.disabled]. + final bool disabled; + + /// See [AsyncButton.switchDuration]. + final Duration? switchDuration; + + /// See [AsyncButton.transitionBuilder]. + final AnimatedSwitcherTransitionBuilder? transitionBuilder; + + /// See [AsyncButton.successDisplayDuration]. + final Duration? successDisplayDuration; + + /// See [AsyncButton.errorDisplayDuration]. + final Duration? errorDisplayDuration; + + /// See [AsyncButton.cooldownDuration]. + final Duration? cooldownDuration; + + /// See [AsyncButton.animateSize]. + final bool? animateSize; + + /// See [AsyncButton.hapticOn]. + final HapticOn? hapticOn; + + /// See [AsyncButton.announceSemantics]. + final bool? announceSemantics; + + /// See [AsyncButton.rethrowErrors]. + final bool? rethrowErrors; +} + +/// Sub-base for the four [AsyncMaterialButton]s that share the standard +/// [ButtonStyleButton] surface ([ElevatedAsyncButton], [FilledAsyncButton], +/// [OutlinedAsyncButton], [TextAsyncButton]). Centralises the common +/// Material parameters and the `.icon` constructor pieces so each concrete +/// subclass only has to render its specific button widget. +/// +/// [IconAsyncButton] does not extend this — it carries a different field +/// set ([IconButton]'s API). +abstract class AsyncStandardMaterialButton extends AsyncMaterialButton { + /// Subclass-only constructor. Adds Material parameters common to + /// [ElevatedButton]/[FilledButton]/[OutlinedButton]/[TextButton]. + const AsyncStandardMaterialButton({ + super.key, + required super.child, + required super.onPressed, + super.controller, + super.onSuccess, + super.onError, + super.onStateChanged, + super.confirmBeforePress, + super.loadingChild, + super.successChild, + super.errorChild, + super.disabled, + super.switchDuration, + super.transitionBuilder, + super.successDisplayDuration, + super.errorDisplayDuration, + super.cooldownDuration, + super.animateSize, + super.hapticOn, + super.announceSemantics, + super.rethrowErrors, + this.onLongPress, + this.onHover, + this.onFocusChange, + this.style, + this.focusNode, + this.autofocus = false, + this.clipBehavior, + this.statesController, + Widget? icon, + IconAlignment? iconAlignment, + }) : _icon = icon, + _iconAlignment = iconAlignment; + + /// Forwarded to the underlying Material button. + final VoidCallback? onLongPress; + + /// Forwarded to the underlying Material button. + final ValueChanged? onHover; + + /// Forwarded to the underlying Material button. + final ValueChanged? onFocusChange; + + /// Forwarded to the underlying Material button. + final ButtonStyle? style; + + /// Forwarded to the underlying Material button. + final FocusNode? focusNode; + + /// Forwarded to the underlying Material button. + final bool autofocus; + + /// Forwarded to the underlying Material button. + final Clip? clipBehavior; + + /// Forwarded to the underlying Material button. + final WidgetStatesController? statesController; + + final Widget? _icon; + final IconAlignment? _iconAlignment; +} diff --git a/lib/src/buttons/elevated_async_button.dart b/lib/src/buttons/elevated_async_button.dart new file mode 100644 index 0000000..974457f --- /dev/null +++ b/lib/src/buttons/elevated_async_button.dart @@ -0,0 +1,133 @@ +part of '../../material_async_button.dart'; + +/// Async-aware [ElevatedButton]. While `onPressed` is running the label is +/// swapped for a loading widget; success/error are shown afterwards if +/// configured via prop or theme. +class ElevatedAsyncButton extends AsyncStandardMaterialButton { + /// Mirrors [ElevatedButton.new]. + const ElevatedAsyncButton({ + super.key, + required super.onPressed, + required super.child, + super.onLongPress, + super.onHover, + super.onFocusChange, + super.style, + super.focusNode, + super.autofocus, + super.clipBehavior, + super.statesController, + super.controller, + super.onSuccess, + super.onError, + super.onStateChanged, + super.confirmBeforePress, + super.loadingChild, + super.successChild, + super.errorChild, + super.disabled, + super.switchDuration, + super.transitionBuilder, + super.successDisplayDuration, + super.errorDisplayDuration, + super.cooldownDuration, + super.animateSize, + super.hapticOn, + super.announceSemantics, + super.rethrowErrors, + }); + + /// Mirrors [ElevatedButton.icon]. The loading/success/error children + /// replace `label` while `icon` stays put. + const ElevatedAsyncButton.icon({ + super.key, + required super.onPressed, + super.onLongPress, + super.onHover, + super.onFocusChange, + super.style, + super.focusNode, + super.autofocus, + super.clipBehavior, + super.statesController, + super.iconAlignment, + super.controller, + super.onSuccess, + super.onError, + super.onStateChanged, + super.confirmBeforePress, + super.loadingChild, + super.successChild, + super.errorChild, + super.disabled, + super.switchDuration, + super.transitionBuilder, + super.successDisplayDuration, + super.errorDisplayDuration, + super.cooldownDuration, + super.animateSize, + super.hapticOn, + super.announceSemantics, + super.rethrowErrors, + required Widget icon, + required Widget label, + }) : super(icon: icon, child: label); + + @override + Widget build(BuildContext context) { + return AsyncButton( + onPressed: onPressed, + controller: controller, + onSuccess: onSuccess, + onError: onError, + onStateChanged: onStateChanged, + confirmBeforePress: confirmBeforePress, + loadingChild: loadingChild, + successChild: successChild, + errorChild: errorChild, + disabled: disabled, + switchDuration: switchDuration, + transitionBuilder: transitionBuilder, + successDisplayDuration: successDisplayDuration, + errorDisplayDuration: errorDisplayDuration, + cooldownDuration: cooldownDuration, + animateSize: animateSize, + hapticOn: hapticOn, + announceSemantics: announceSemantics, + rethrowErrors: rethrowErrors, + builder: (context, animatedChild, callback, status) { + final clip = clipBehavior ?? .none; + final longPress = callback == null ? null : onLongPress; + if (_icon != null) { + return ElevatedButton.icon( + onPressed: callback, + onLongPress: longPress, + onHover: onHover, + onFocusChange: onFocusChange, + style: style, + focusNode: focusNode, + autofocus: autofocus, + clipBehavior: clip, + statesController: statesController, + iconAlignment: _iconAlignment, + icon: _icon, + label: animatedChild, + ); + } + return ElevatedButton( + onPressed: callback, + onLongPress: longPress, + onHover: onHover, + onFocusChange: onFocusChange, + style: style, + focusNode: focusNode, + autofocus: autofocus, + clipBehavior: clip, + statesController: statesController, + child: animatedChild, + ); + }, + child: child, + ); + } +} diff --git a/lib/src/buttons/filled_async_button.dart b/lib/src/buttons/filled_async_button.dart new file mode 100644 index 0000000..2b4ee58 --- /dev/null +++ b/lib/src/buttons/filled_async_button.dart @@ -0,0 +1,236 @@ +part of '../../material_async_button.dart'; + +enum _FilledVariant { primary, tonal } + +/// Async-aware [FilledButton] with all four Material 3 flavors: +/// [FilledAsyncButton.new], [FilledAsyncButton.tonal], +/// [FilledAsyncButton.icon], [FilledAsyncButton.tonalIcon]. +class FilledAsyncButton extends AsyncStandardMaterialButton { + /// Mirrors [FilledButton.new]. + const FilledAsyncButton({ + super.key, + required super.onPressed, + required super.child, + super.onLongPress, + super.onHover, + super.onFocusChange, + super.style, + super.focusNode, + super.autofocus, + super.clipBehavior, + super.statesController, + super.controller, + super.onSuccess, + super.onError, + super.onStateChanged, + super.confirmBeforePress, + super.loadingChild, + super.successChild, + super.errorChild, + super.disabled, + super.switchDuration, + super.transitionBuilder, + super.successDisplayDuration, + super.errorDisplayDuration, + super.cooldownDuration, + super.animateSize, + super.hapticOn, + super.announceSemantics, + super.rethrowErrors, + }) : _variant = .primary; + + /// Mirrors [FilledButton.tonal]. + const FilledAsyncButton.tonal({ + super.key, + required super.onPressed, + required super.child, + super.onLongPress, + super.onHover, + super.onFocusChange, + super.style, + super.focusNode, + super.autofocus, + super.clipBehavior, + super.statesController, + super.controller, + super.onSuccess, + super.onError, + super.onStateChanged, + super.confirmBeforePress, + super.loadingChild, + super.successChild, + super.errorChild, + super.disabled, + super.switchDuration, + super.transitionBuilder, + super.successDisplayDuration, + super.errorDisplayDuration, + super.cooldownDuration, + super.animateSize, + super.hapticOn, + super.announceSemantics, + super.rethrowErrors, + }) : _variant = .tonal; + + /// Mirrors [FilledButton.icon]. + const FilledAsyncButton.icon({ + super.key, + required super.onPressed, + super.onLongPress, + super.onHover, + super.onFocusChange, + super.style, + super.focusNode, + super.autofocus, + super.clipBehavior, + super.statesController, + super.iconAlignment, + super.controller, + super.onSuccess, + super.onError, + super.onStateChanged, + super.confirmBeforePress, + super.loadingChild, + super.successChild, + super.errorChild, + super.disabled, + super.switchDuration, + super.transitionBuilder, + super.successDisplayDuration, + super.errorDisplayDuration, + super.cooldownDuration, + super.animateSize, + super.hapticOn, + super.announceSemantics, + super.rethrowErrors, + required Widget icon, + required Widget label, + }) : _variant = .primary, + super(icon: icon, child: label); + + /// Mirrors [FilledButton.tonalIcon]. + const FilledAsyncButton.tonalIcon({ + super.key, + required super.onPressed, + super.onLongPress, + super.onHover, + super.onFocusChange, + super.style, + super.focusNode, + super.autofocus, + super.clipBehavior, + super.statesController, + super.iconAlignment, + super.controller, + super.onSuccess, + super.onError, + super.onStateChanged, + super.confirmBeforePress, + super.loadingChild, + super.successChild, + super.errorChild, + super.disabled, + super.switchDuration, + super.transitionBuilder, + super.successDisplayDuration, + super.errorDisplayDuration, + super.cooldownDuration, + super.animateSize, + super.hapticOn, + super.announceSemantics, + super.rethrowErrors, + required Widget icon, + required Widget label, + }) : _variant = .tonal, + super(icon: icon, child: label); + + final _FilledVariant _variant; + + @override + Widget build(BuildContext context) { + return AsyncButton( + onPressed: onPressed, + controller: controller, + onSuccess: onSuccess, + onError: onError, + onStateChanged: onStateChanged, + confirmBeforePress: confirmBeforePress, + loadingChild: loadingChild, + successChild: successChild, + errorChild: errorChild, + disabled: disabled, + switchDuration: switchDuration, + transitionBuilder: transitionBuilder, + successDisplayDuration: successDisplayDuration, + errorDisplayDuration: errorDisplayDuration, + cooldownDuration: cooldownDuration, + animateSize: animateSize, + hapticOn: hapticOn, + announceSemantics: announceSemantics, + rethrowErrors: rethrowErrors, + builder: (context, animatedChild, callback, status) { + final clip = clipBehavior ?? .none; + final longPress = callback == null ? null : onLongPress; + if (_icon != null) { + return switch (_variant) { + .primary => FilledButton.icon( + onPressed: callback, + onLongPress: longPress, + onHover: onHover, + onFocusChange: onFocusChange, + style: style, + focusNode: focusNode, + autofocus: autofocus, + clipBehavior: clip, + statesController: statesController, + iconAlignment: _iconAlignment, + icon: _icon, + label: animatedChild, + ), + .tonal => FilledButton.tonalIcon( + onPressed: callback, + onLongPress: longPress, + onHover: onHover, + onFocusChange: onFocusChange, + style: style, + focusNode: focusNode, + autofocus: autofocus, + clipBehavior: clip, + statesController: statesController, + iconAlignment: _iconAlignment, + icon: _icon, + label: animatedChild, + ), + }; + } + return switch (_variant) { + .primary => FilledButton( + onPressed: callback, + onLongPress: longPress, + onHover: onHover, + onFocusChange: onFocusChange, + style: style, + focusNode: focusNode, + autofocus: autofocus, + clipBehavior: clip, + statesController: statesController, + child: animatedChild, + ), + .tonal => FilledButton.tonal( + onPressed: callback, + onLongPress: longPress, + onHover: onHover, + onFocusChange: onFocusChange, + style: style, + focusNode: focusNode, + autofocus: autofocus, + clipBehavior: clip, + statesController: statesController, + child: animatedChild, + ), + }; + }, + child: child, + ); + } +} diff --git a/lib/src/buttons/icon_async_button.dart b/lib/src/buttons/icon_async_button.dart new file mode 100644 index 0000000..b30ffca --- /dev/null +++ b/lib/src/buttons/icon_async_button.dart @@ -0,0 +1,386 @@ +part of '../../material_async_button.dart'; + +enum _IconVariant { standard, filled, filledTonal, outlined } + +/// Async-aware [IconButton]. Includes all four Material 3 flavors: +/// [IconAsyncButton.new], [IconAsyncButton.filled], +/// [IconAsyncButton.filledTonal], [IconAsyncButton.outlined]. +/// +/// The [icon] is swapped with `loadingChild`/`successChild`/`errorChild` +/// during the corresponding state. +class IconAsyncButton extends AsyncMaterialButton { + /// Mirrors [IconButton.new]. + const IconAsyncButton({ + super.key, + required super.onPressed, + super.controller, + super.onSuccess, + super.onError, + super.onStateChanged, + super.confirmBeforePress, + super.loadingChild, + super.successChild, + super.errorChild, + super.disabled, + super.switchDuration, + super.transitionBuilder, + super.successDisplayDuration, + super.errorDisplayDuration, + super.cooldownDuration, + super.animateSize, + super.hapticOn, + super.announceSemantics, + super.rethrowErrors, + required this.icon, + this.iconSize, + this.visualDensity, + this.padding, + this.alignment, + this.splashRadius, + this.color, + this.focusColor, + this.hoverColor, + this.highlightColor, + this.splashColor, + this.disabledColor, + this.mouseCursor, + this.focusNode, + this.autofocus = false, + this.tooltip, + this.enableFeedback, + this.constraints, + this.style, + this.isSelected, + this.selectedIcon, + }) : _variant = .standard, + super(child: icon); + + /// Mirrors [IconButton.filled]. + const IconAsyncButton.filled({ + super.key, + required super.onPressed, + super.controller, + super.onSuccess, + super.onError, + super.onStateChanged, + super.confirmBeforePress, + super.loadingChild, + super.successChild, + super.errorChild, + super.disabled, + super.switchDuration, + super.transitionBuilder, + super.successDisplayDuration, + super.errorDisplayDuration, + super.cooldownDuration, + super.animateSize, + super.hapticOn, + super.announceSemantics, + super.rethrowErrors, + required this.icon, + this.iconSize, + this.visualDensity, + this.padding, + this.alignment, + this.splashRadius, + this.color, + this.focusColor, + this.hoverColor, + this.highlightColor, + this.splashColor, + this.disabledColor, + this.mouseCursor, + this.focusNode, + this.autofocus = false, + this.tooltip, + this.enableFeedback, + this.constraints, + this.style, + this.isSelected, + this.selectedIcon, + }) : _variant = .filled, + super(child: icon); + + /// Mirrors [IconButton.filledTonal]. + const IconAsyncButton.filledTonal({ + super.key, + required super.onPressed, + super.controller, + super.onSuccess, + super.onError, + super.onStateChanged, + super.confirmBeforePress, + super.loadingChild, + super.successChild, + super.errorChild, + super.disabled, + super.switchDuration, + super.transitionBuilder, + super.successDisplayDuration, + super.errorDisplayDuration, + super.cooldownDuration, + super.animateSize, + super.hapticOn, + super.announceSemantics, + super.rethrowErrors, + required this.icon, + this.iconSize, + this.visualDensity, + this.padding, + this.alignment, + this.splashRadius, + this.color, + this.focusColor, + this.hoverColor, + this.highlightColor, + this.splashColor, + this.disabledColor, + this.mouseCursor, + this.focusNode, + this.autofocus = false, + this.tooltip, + this.enableFeedback, + this.constraints, + this.style, + this.isSelected, + this.selectedIcon, + }) : _variant = .filledTonal, + super(child: icon); + + /// Mirrors [IconButton.outlined]. + const IconAsyncButton.outlined({ + super.key, + required super.onPressed, + super.controller, + super.onSuccess, + super.onError, + super.onStateChanged, + super.confirmBeforePress, + super.loadingChild, + super.successChild, + super.errorChild, + super.disabled, + super.switchDuration, + super.transitionBuilder, + super.successDisplayDuration, + super.errorDisplayDuration, + super.cooldownDuration, + super.animateSize, + super.hapticOn, + super.announceSemantics, + super.rethrowErrors, + required this.icon, + this.iconSize, + this.visualDensity, + this.padding, + this.alignment, + this.splashRadius, + this.color, + this.focusColor, + this.hoverColor, + this.highlightColor, + this.splashColor, + this.disabledColor, + this.mouseCursor, + this.focusNode, + this.autofocus = false, + this.tooltip, + this.enableFeedback, + this.constraints, + this.style, + this.isSelected, + this.selectedIcon, + }) : _variant = .outlined, + super(child: icon); + + /// Idle icon. Swapped with `loadingChild`/`successChild`/`errorChild`. + final Widget icon; + + /// Forwarded to the underlying [IconButton]. + final double? iconSize; + + /// Forwarded to the underlying [IconButton]. + final VisualDensity? visualDensity; + + /// Forwarded to the underlying [IconButton]. + final EdgeInsetsGeometry? padding; + + /// Forwarded to the underlying [IconButton]. + final AlignmentGeometry? alignment; + + /// Forwarded to the underlying [IconButton]. + final double? splashRadius; + + /// Forwarded to the underlying [IconButton]. + final Color? color; + + /// Forwarded to the underlying [IconButton]. + final Color? focusColor; + + /// Forwarded to the underlying [IconButton]. + final Color? hoverColor; + + /// Forwarded to the underlying [IconButton]. + final Color? highlightColor; + + /// Forwarded to the underlying [IconButton]. + final Color? splashColor; + + /// Forwarded to the underlying [IconButton]. + final Color? disabledColor; + + /// Forwarded to the underlying [IconButton]. + final MouseCursor? mouseCursor; + + /// Forwarded to the underlying [IconButton]. + final FocusNode? focusNode; + + /// Forwarded to the underlying [IconButton]. + final bool autofocus; + + /// Forwarded to the underlying [IconButton]. + final String? tooltip; + + /// Forwarded to the underlying [IconButton]. + final bool? enableFeedback; + + /// Forwarded to the underlying [IconButton]. + final BoxConstraints? constraints; + + /// Forwarded to the underlying [IconButton]. + final ButtonStyle? style; + + /// Forwarded to the underlying [IconButton]. + final bool? isSelected; + + /// Forwarded to the underlying [IconButton]. + final Widget? selectedIcon; + + final _IconVariant _variant; + + @override + Widget build(BuildContext context) { + return AsyncButton( + onPressed: onPressed, + controller: controller, + onSuccess: onSuccess, + onError: onError, + onStateChanged: onStateChanged, + confirmBeforePress: confirmBeforePress, + loadingChild: loadingChild, + successChild: successChild, + errorChild: errorChild, + disabled: disabled, + switchDuration: switchDuration, + transitionBuilder: transitionBuilder, + successDisplayDuration: successDisplayDuration, + errorDisplayDuration: errorDisplayDuration, + cooldownDuration: cooldownDuration, + animateSize: animateSize, + hapticOn: hapticOn, + announceSemantics: announceSemantics, + rethrowErrors: rethrowErrors, + builder: (context, child, callback, status) { + return switch (_variant) { + .standard => IconButton( + onPressed: callback, + icon: child, + iconSize: iconSize, + visualDensity: visualDensity, + padding: padding, + alignment: alignment, + splashRadius: splashRadius, + color: color, + focusColor: focusColor, + hoverColor: hoverColor, + highlightColor: highlightColor, + splashColor: splashColor, + disabledColor: disabledColor, + mouseCursor: mouseCursor, + focusNode: focusNode, + autofocus: autofocus, + tooltip: tooltip, + enableFeedback: enableFeedback, + constraints: constraints, + style: style, + isSelected: isSelected, + selectedIcon: selectedIcon, + ), + .filled => IconButton.filled( + onPressed: callback, + icon: child, + iconSize: iconSize, + visualDensity: visualDensity, + padding: padding, + alignment: alignment, + splashRadius: splashRadius, + color: color, + focusColor: focusColor, + hoverColor: hoverColor, + highlightColor: highlightColor, + splashColor: splashColor, + disabledColor: disabledColor, + mouseCursor: mouseCursor, + focusNode: focusNode, + autofocus: autofocus, + tooltip: tooltip, + enableFeedback: enableFeedback, + constraints: constraints, + style: style, + isSelected: isSelected, + selectedIcon: selectedIcon, + ), + .filledTonal => IconButton.filledTonal( + onPressed: callback, + icon: child, + iconSize: iconSize, + visualDensity: visualDensity, + padding: padding, + alignment: alignment, + splashRadius: splashRadius, + color: color, + focusColor: focusColor, + hoverColor: hoverColor, + highlightColor: highlightColor, + splashColor: splashColor, + disabledColor: disabledColor, + mouseCursor: mouseCursor, + focusNode: focusNode, + autofocus: autofocus, + tooltip: tooltip, + enableFeedback: enableFeedback, + constraints: constraints, + style: style, + isSelected: isSelected, + selectedIcon: selectedIcon, + ), + .outlined => IconButton.outlined( + onPressed: callback, + icon: child, + iconSize: iconSize, + visualDensity: visualDensity, + padding: padding, + alignment: alignment, + splashRadius: splashRadius, + color: color, + focusColor: focusColor, + hoverColor: hoverColor, + highlightColor: highlightColor, + splashColor: splashColor, + disabledColor: disabledColor, + mouseCursor: mouseCursor, + focusNode: focusNode, + autofocus: autofocus, + tooltip: tooltip, + enableFeedback: enableFeedback, + constraints: constraints, + style: style, + isSelected: isSelected, + selectedIcon: selectedIcon, + ), + }; + }, + child: icon, + ); + } +} diff --git a/lib/src/buttons/outlined_async_button.dart b/lib/src/buttons/outlined_async_button.dart new file mode 100644 index 0000000..2c21cf8 --- /dev/null +++ b/lib/src/buttons/outlined_async_button.dart @@ -0,0 +1,130 @@ +part of '../../material_async_button.dart'; + +/// Async-aware [OutlinedButton]. +class OutlinedAsyncButton extends AsyncStandardMaterialButton { + /// Mirrors [OutlinedButton.new]. + const OutlinedAsyncButton({ + super.key, + required super.onPressed, + required super.child, + super.onLongPress, + super.onHover, + super.onFocusChange, + super.style, + super.focusNode, + super.autofocus, + super.clipBehavior, + super.statesController, + super.controller, + super.onSuccess, + super.onError, + super.onStateChanged, + super.confirmBeforePress, + super.loadingChild, + super.successChild, + super.errorChild, + super.disabled, + super.switchDuration, + super.transitionBuilder, + super.successDisplayDuration, + super.errorDisplayDuration, + super.cooldownDuration, + super.animateSize, + super.hapticOn, + super.announceSemantics, + super.rethrowErrors, + }); + + /// Mirrors [OutlinedButton.icon]. + const OutlinedAsyncButton.icon({ + super.key, + required super.onPressed, + super.onLongPress, + super.onHover, + super.onFocusChange, + super.style, + super.focusNode, + super.autofocus, + super.clipBehavior, + super.statesController, + super.iconAlignment, + super.controller, + super.onSuccess, + super.onError, + super.onStateChanged, + super.confirmBeforePress, + super.loadingChild, + super.successChild, + super.errorChild, + super.disabled, + super.switchDuration, + super.transitionBuilder, + super.successDisplayDuration, + super.errorDisplayDuration, + super.cooldownDuration, + super.animateSize, + super.hapticOn, + super.announceSemantics, + super.rethrowErrors, + required Widget icon, + required Widget label, + }) : super(icon: icon, child: label); + + @override + Widget build(BuildContext context) { + return AsyncButton( + onPressed: onPressed, + controller: controller, + onSuccess: onSuccess, + onError: onError, + onStateChanged: onStateChanged, + confirmBeforePress: confirmBeforePress, + loadingChild: loadingChild, + successChild: successChild, + errorChild: errorChild, + disabled: disabled, + switchDuration: switchDuration, + transitionBuilder: transitionBuilder, + successDisplayDuration: successDisplayDuration, + errorDisplayDuration: errorDisplayDuration, + cooldownDuration: cooldownDuration, + animateSize: animateSize, + hapticOn: hapticOn, + announceSemantics: announceSemantics, + rethrowErrors: rethrowErrors, + builder: (context, animatedChild, callback, status) { + final clip = clipBehavior ?? .none; + final longPress = callback == null ? null : onLongPress; + if (_icon != null) { + return OutlinedButton.icon( + onPressed: callback, + onLongPress: longPress, + onHover: onHover, + onFocusChange: onFocusChange, + style: style, + focusNode: focusNode, + autofocus: autofocus, + clipBehavior: clip, + statesController: statesController, + iconAlignment: _iconAlignment, + icon: _icon, + label: animatedChild, + ); + } + return OutlinedButton( + onPressed: callback, + onLongPress: longPress, + onHover: onHover, + onFocusChange: onFocusChange, + style: style, + focusNode: focusNode, + autofocus: autofocus, + clipBehavior: clip, + statesController: statesController, + child: animatedChild, + ); + }, + child: child, + ); + } +} diff --git a/lib/src/buttons/text_async_button.dart b/lib/src/buttons/text_async_button.dart new file mode 100644 index 0000000..3238db8 --- /dev/null +++ b/lib/src/buttons/text_async_button.dart @@ -0,0 +1,130 @@ +part of '../../material_async_button.dart'; + +/// Async-aware [TextButton]. +class TextAsyncButton extends AsyncStandardMaterialButton { + /// Mirrors [TextButton.new]. + const TextAsyncButton({ + super.key, + required super.onPressed, + required super.child, + super.onLongPress, + super.onHover, + super.onFocusChange, + super.style, + super.focusNode, + super.autofocus, + super.clipBehavior, + super.statesController, + super.controller, + super.onSuccess, + super.onError, + super.onStateChanged, + super.confirmBeforePress, + super.loadingChild, + super.successChild, + super.errorChild, + super.disabled, + super.switchDuration, + super.transitionBuilder, + super.successDisplayDuration, + super.errorDisplayDuration, + super.cooldownDuration, + super.animateSize, + super.hapticOn, + super.announceSemantics, + super.rethrowErrors, + }); + + /// Mirrors [TextButton.icon]. + const TextAsyncButton.icon({ + super.key, + required super.onPressed, + super.onLongPress, + super.onHover, + super.onFocusChange, + super.style, + super.focusNode, + super.autofocus, + super.clipBehavior, + super.statesController, + super.iconAlignment, + super.controller, + super.onSuccess, + super.onError, + super.onStateChanged, + super.confirmBeforePress, + super.loadingChild, + super.successChild, + super.errorChild, + super.disabled, + super.switchDuration, + super.transitionBuilder, + super.successDisplayDuration, + super.errorDisplayDuration, + super.cooldownDuration, + super.animateSize, + super.hapticOn, + super.announceSemantics, + super.rethrowErrors, + required Widget icon, + required Widget label, + }) : super(icon: icon, child: label); + + @override + Widget build(BuildContext context) { + return AsyncButton( + onPressed: onPressed, + controller: controller, + onSuccess: onSuccess, + onError: onError, + onStateChanged: onStateChanged, + confirmBeforePress: confirmBeforePress, + loadingChild: loadingChild, + successChild: successChild, + errorChild: errorChild, + disabled: disabled, + switchDuration: switchDuration, + transitionBuilder: transitionBuilder, + successDisplayDuration: successDisplayDuration, + errorDisplayDuration: errorDisplayDuration, + cooldownDuration: cooldownDuration, + animateSize: animateSize, + hapticOn: hapticOn, + announceSemantics: announceSemantics, + rethrowErrors: rethrowErrors, + builder: (context, animatedChild, callback, status) { + final clip = clipBehavior ?? .none; + final longPress = callback == null ? null : onLongPress; + if (_icon != null) { + return TextButton.icon( + onPressed: callback, + onLongPress: longPress, + onHover: onHover, + onFocusChange: onFocusChange, + style: style, + focusNode: focusNode, + autofocus: autofocus, + clipBehavior: clip, + statesController: statesController, + iconAlignment: _iconAlignment, + icon: _icon, + label: animatedChild, + ); + } + return TextButton( + onPressed: callback, + onLongPress: longPress, + onHover: onHover, + onFocusChange: onFocusChange, + style: style, + focusNode: focusNode, + autofocus: autofocus, + clipBehavior: clip, + statesController: statesController, + child: animatedChild, + ); + }, + child: child, + ); + } +} diff --git a/lib/src/material_async_button_theme.dart b/lib/src/material_async_button_theme.dart new file mode 100644 index 0000000..18e27d0 --- /dev/null +++ b/lib/src/material_async_button_theme.dart @@ -0,0 +1,381 @@ +part of '../material_async_button.dart'; + +/// Which haptic event, if any, to fire on state transitions. +enum HapticOn { + /// Fire no haptic on state transitions. + none, + + /// Fire a light haptic on success only. + success, + + /// Fire a light haptic on error only. + error, + + /// Fire a light haptic on both success and error. + both, +} + +/// App-wide defaults for `material_async_button` widgets, attached as a +/// [ThemeExtension] on [ThemeData]. +/// +/// Resolution order for any field: per-widget value, then theme value, then +/// the hard-coded fallback documented on each field. +/// +/// Use [AsyncButtonTheme.material] for an opinionated baseline that +/// mirrors what most apps want; otherwise build the extension yourself with +/// only the fields you care about. +/// +/// ```dart +/// MaterialApp( +/// theme: ThemeData(extensions: [AsyncButtonTheme.material()]), +/// ) +/// ``` +@immutable +class AsyncButtonTheme extends ThemeExtension { + /// Builds an [AsyncButtonTheme]. Every field is nullable so callers only + /// set the ones they want. + const AsyncButtonTheme({ + this.loadingChild, + this.successChild, + this.errorChild, + this.switchDuration, + this.switchReverseDuration, + this.switchCurve, + this.switchInCurve, + this.switchOutCurve, + this.transitionBuilder, + this.successDisplayDuration, + this.errorDisplayDuration, + this.cooldownDuration, + this.animateSize, + this.sizeCurve, + this.sizeAlignment, + this.sizeClipBehavior, + this.hapticOn, + this.announceSemantics, + this.rethrowErrors, + }); + + /// Convenience: an opinionated baseline. + /// + /// - 16x16 indeterminate spinner during loading + /// - check icon for 800ms after success + /// - error icon for 800ms after error + /// - 200ms cross-fade between states + /// - light haptic on success and error + /// - announces state changes to assistive tech + factory AsyncButtonTheme.material({ + Color? loadingColor, + Color? successColor, + Color? errorColor, + }) { + return AsyncButtonTheme( + loadingChild: _DefaultLoadingChild(color: loadingColor), + successChild: _DefaultSuccessIcon(color: successColor), + errorChild: _DefaultErrorIcon(color: errorColor), + switchDuration: const Duration(milliseconds: 200), + switchReverseDuration: const Duration(milliseconds: 200), + successDisplayDuration: const Duration(milliseconds: 800), + errorDisplayDuration: const Duration(milliseconds: 800), + animateSize: true, + hapticOn: .both, + announceSemantics: true, + ); + } + + /// Shown in place of the button's child while the future is in flight. + /// Falls back to a 16x16 [CircularProgressIndicator] when null. + final Widget? loadingChild; + + /// Shown after success for [successDisplayDuration]. Null = keep the + /// original child visible (no visual swap, only the idle delay applies). + final Widget? successChild; + + /// Shown after error for [errorDisplayDuration]. Null = keep the + /// original child visible. + final Widget? errorChild; + + /// Cross-fade duration between state widgets. + /// Falls back to 200ms when null. + final Duration? switchDuration; + + /// Reverse cross-fade duration. Falls back to [switchDuration] when null. + final Duration? switchReverseDuration; + + /// Convenience: applied to both [switchInCurve] and [switchOutCurve] + /// unless one of those is set explicitly. + final Curve? switchCurve; + + /// Curve for the incoming widget during the cross-fade. + final Curve? switchInCurve; + + /// Curve for the outgoing widget during the cross-fade. + final Curve? switchOutCurve; + + /// Custom transition for the underlying [AnimatedSwitcher]. Defaults to a + /// fade transition. + final AnimatedSwitcherTransitionBuilder? transitionBuilder; + + /// How long [successChild] is shown before returning to idle. Defaults to + /// [.zero] (immediate return). + final Duration? successDisplayDuration; + + /// How long [errorChild] is shown before returning to idle. Defaults to + /// [.zero]. + final Duration? errorDisplayDuration; + + /// After a success/error display, keep the button disabled for this long + /// to prevent accidental double-submits. Defaults to [.zero]. + final Duration? cooldownDuration; + + /// Whether to animate the implicit size between state widgets of differing + /// dimensions. Falls back to `false`. + final bool? animateSize; + + /// Curve used by the [AnimatedSize] when [animateSize] is true. + final Curve? sizeCurve; + + /// Alignment used by the [AnimatedSize] when [animateSize] is true. + final AlignmentGeometry? sizeAlignment; + + /// Clip behavior used by the [AnimatedSize] when [animateSize] is true. + final Clip? sizeClipBehavior; + + /// Whether to fire a [HapticFeedback] on success / error transitions. + /// Defaults to [HapticOn.none]. + final HapticOn? hapticOn; + + /// Whether to announce state changes via + /// [SemanticsService.sendAnnouncement]. Defaults to `false`. + final bool? announceSemantics; + + /// If true, errors from `onPressed` are rethrown after the error state is + /// displayed. Useful when a caller awaits the future. Defaults to `false`. + final bool? rethrowErrors; + + /// An extension with every field left null. Use this to opt out of the + /// opinionated [AsyncButtonTheme.material] baseline returned by [of] when + /// no extension is registered on the surrounding [ThemeData]. + static const AsyncButtonTheme empty = AsyncButtonTheme(); + + static final AsyncButtonTheme _materialDefaults = AsyncButtonTheme.material(); + + /// Resolves the [AsyncButtonTheme] visible at [context]. Returns the + /// extension registered on the surrounding [ThemeData] when one exists; + /// otherwise falls back to [AsyncButtonTheme.material] so that apps + /// without explicit theming still get the spinner / check / error UX + /// out of the box. Pass [empty] to opt out. + static AsyncButtonTheme of(BuildContext context) { + return Theme.of(context).extension() ?? _materialDefaults; + } + + @override + AsyncButtonTheme copyWith({ + Widget? loadingChild, + Widget? successChild, + Widget? errorChild, + Duration? switchDuration, + Duration? switchReverseDuration, + Curve? switchCurve, + Curve? switchInCurve, + Curve? switchOutCurve, + AnimatedSwitcherTransitionBuilder? transitionBuilder, + Duration? successDisplayDuration, + Duration? errorDisplayDuration, + Duration? cooldownDuration, + bool? animateSize, + Curve? sizeCurve, + AlignmentGeometry? sizeAlignment, + Clip? sizeClipBehavior, + HapticOn? hapticOn, + bool? announceSemantics, + bool? rethrowErrors, + }) { + return AsyncButtonTheme( + loadingChild: loadingChild ?? this.loadingChild, + successChild: successChild ?? this.successChild, + errorChild: errorChild ?? this.errorChild, + switchDuration: switchDuration ?? this.switchDuration, + switchReverseDuration: + switchReverseDuration ?? this.switchReverseDuration, + switchCurve: switchCurve ?? this.switchCurve, + switchInCurve: switchInCurve ?? this.switchInCurve, + switchOutCurve: switchOutCurve ?? this.switchOutCurve, + transitionBuilder: transitionBuilder ?? this.transitionBuilder, + successDisplayDuration: + successDisplayDuration ?? this.successDisplayDuration, + errorDisplayDuration: errorDisplayDuration ?? this.errorDisplayDuration, + cooldownDuration: cooldownDuration ?? this.cooldownDuration, + animateSize: animateSize ?? this.animateSize, + sizeCurve: sizeCurve ?? this.sizeCurve, + sizeAlignment: sizeAlignment ?? this.sizeAlignment, + sizeClipBehavior: sizeClipBehavior ?? this.sizeClipBehavior, + hapticOn: hapticOn ?? this.hapticOn, + announceSemantics: announceSemantics ?? this.announceSemantics, + rethrowErrors: rethrowErrors ?? this.rethrowErrors, + ); + } + + @override + AsyncButtonTheme lerp( + covariant ThemeExtension? other, + double t, + ) { + if (other is! AsyncButtonTheme) { + return this; + } + // Widgets and enums don't lerp meaningfully; snap at the halfway point. + final snap = t < 0.5; + return AsyncButtonTheme( + loadingChild: snap ? loadingChild : other.loadingChild, + successChild: snap ? successChild : other.successChild, + errorChild: snap ? errorChild : other.errorChild, + switchDuration: _lerpDuration( + switchDuration, + other.switchDuration, + t, + ), + switchReverseDuration: _lerpDuration( + switchReverseDuration, + other.switchReverseDuration, + t, + ), + switchCurve: snap ? switchCurve : other.switchCurve, + switchInCurve: snap ? switchInCurve : other.switchInCurve, + switchOutCurve: snap ? switchOutCurve : other.switchOutCurve, + transitionBuilder: snap ? transitionBuilder : other.transitionBuilder, + successDisplayDuration: _lerpDuration( + successDisplayDuration, + other.successDisplayDuration, + t, + ), + errorDisplayDuration: _lerpDuration( + errorDisplayDuration, + other.errorDisplayDuration, + t, + ), + cooldownDuration: _lerpDuration( + cooldownDuration, + other.cooldownDuration, + t, + ), + animateSize: snap ? animateSize : other.animateSize, + sizeCurve: snap ? sizeCurve : other.sizeCurve, + sizeAlignment: .lerp( + sizeAlignment, + other.sizeAlignment, + t, + ), + sizeClipBehavior: snap ? sizeClipBehavior : other.sizeClipBehavior, + hapticOn: snap ? hapticOn : other.hapticOn, + announceSemantics: snap ? announceSemantics : other.announceSemantics, + rethrowErrors: snap ? rethrowErrors : other.rethrowErrors, + ); + } + + static Duration? _lerpDuration(Duration? a, Duration? b, double t) { + if (a == null && b == null) { + return null; + } + final aMs = (a ?? .zero).inMicroseconds; + final bMs = (b ?? .zero).inMicroseconds; + return Duration(microseconds: (aMs + (bMs - aMs) * t).round()); + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + other is AsyncButtonTheme && + loadingChild == other.loadingChild && + successChild == other.successChild && + errorChild == other.errorChild && + switchDuration == other.switchDuration && + switchReverseDuration == other.switchReverseDuration && + switchCurve == other.switchCurve && + switchInCurve == other.switchInCurve && + switchOutCurve == other.switchOutCurve && + transitionBuilder == other.transitionBuilder && + successDisplayDuration == other.successDisplayDuration && + errorDisplayDuration == other.errorDisplayDuration && + cooldownDuration == other.cooldownDuration && + animateSize == other.animateSize && + sizeCurve == other.sizeCurve && + sizeAlignment == other.sizeAlignment && + sizeClipBehavior == other.sizeClipBehavior && + hapticOn == other.hapticOn && + announceSemantics == other.announceSemantics && + rethrowErrors == other.rethrowErrors; + } + + @override + int get hashCode { + return Object.hashAll([ + loadingChild, + successChild, + errorChild, + switchDuration, + switchReverseDuration, + switchCurve, + switchInCurve, + switchOutCurve, + transitionBuilder, + successDisplayDuration, + errorDisplayDuration, + cooldownDuration, + animateSize, + sizeCurve, + sizeAlignment, + sizeClipBehavior, + hapticOn, + announceSemantics, + rethrowErrors, + ]); + } +} + +class _DefaultLoadingChild extends StatelessWidget { + const _DefaultLoadingChild({this.color}); + + final Color? color; + + @override + Widget build(BuildContext context) { + return SizedBox.square( + dimension: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: color == null + ? null + : AlwaysStoppedAnimation(color!), + ), + ); + } +} + +class _DefaultSuccessIcon extends StatelessWidget { + const _DefaultSuccessIcon({this.color}); + + final Color? color; + + @override + Widget build(BuildContext context) { + return Icon( + Icons.check, + color: color ?? Theme.of(context).colorScheme.primary, + ); + } +} + +class _DefaultErrorIcon extends StatelessWidget { + const _DefaultErrorIcon({this.color}); + + final Color? color; + + @override + Widget build(BuildContext context) { + return Icon( + Icons.error, + color: color ?? Theme.of(context).colorScheme.error, + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 71c15f1..f2c57cc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,18 +1,29 @@ -name: async_button_builder -description: A builder to wrap around buttons that handles loading, disabled, - error and success states -version: 3.0.0+1 -homepage: https://github.com/Nolence/async_button/tree/main/packages/async_button_builder +name: material_async_button +description: >- + Drop-in async wrappers for Flutter Material buttons. Adds loading, success, + and error states with theming via ThemeExtension and external control via a + controller. +version: 1.0.0 +repository: https://github.com/esenmx/material_async_button +issue_tracker: https://github.com/esenmx/material_async_button/issues + +topics: + - button + - async + - material + - loading + - form environment: - sdk: ">=3.0.0 <4.0.0" - flutter: ">=2.0.0" + sdk: ^3.10.0 + flutter: ">=3.0.0" dependencies: flutter: sdk: flutter dev_dependencies: + checks: ^0.3.1 flutter_test: sdk: flutter - lint: ^2.8.0 + very_good_analysis: ^10.2.0 diff --git a/screenshots/ezgif-7-4088c909ba83.gif b/screenshots/ezgif-7-4088c909ba83.gif deleted file mode 100644 index 6b6f42e..0000000 Binary files a/screenshots/ezgif-7-4088c909ba83.gif and /dev/null differ diff --git a/screenshots/ezgif-7-61c436edaec2.gif b/screenshots/ezgif-7-61c436edaec2.gif deleted file mode 100644 index d2dce9d..0000000 Binary files a/screenshots/ezgif-7-61c436edaec2.gif and /dev/null differ diff --git a/screenshots/ezgif-7-a971c6afaabf.gif b/screenshots/ezgif-7-a971c6afaabf.gif deleted file mode 100644 index bf0cb61..0000000 Binary files a/screenshots/ezgif-7-a971c6afaabf.gif and /dev/null differ diff --git a/screenshots/ezgif-7-b620d3def232.gif b/screenshots/ezgif-7-b620d3def232.gif deleted file mode 100644 index b8248d0..0000000 Binary files a/screenshots/ezgif-7-b620d3def232.gif and /dev/null differ diff --git a/test/_helpers.dart b/test/_helpers.dart new file mode 100644 index 0000000..594226c --- /dev/null +++ b/test/_helpers.dart @@ -0,0 +1,71 @@ +import 'dart:async'; + +import 'package:checks/checks.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_async_button/material_async_button.dart'; + +/// Wraps a widget in a minimal MaterialApp + Scaffold for testing. +/// +/// Defaults to a [ThemeData] carrying [AsyncButtonTheme.empty] so the tests +/// see the per-widget / hard-coded fallbacks instead of the opinionated +/// [AsyncButtonTheme.material] baseline that `AsyncButtonTheme.of` returns +/// when no extension is registered. +Widget pumpHost(Widget child, {ThemeData? theme}) { + return MaterialApp( + theme: theme ?? ThemeData(extensions: const [AsyncButtonTheme.empty]), + home: Scaffold( + body: Center(child: child), + ), + ); +} + +/// Returns an `(onPressed, completer)` pair. Caller drives the button into +/// loading and decides when to complete or fail. +({AsyncCallback onPressed, Completer completer}) pendingPress() { + final completer = Completer(); + return (onPressed: () => completer.future, completer: completer); +} + +/// Controller pre-attached with `onPressed`/durations. Auto-disposes. +AsyncButtonController attachedController({ + AsyncCallback? onPressed, + Duration successDuration = .zero, + Duration errorDuration = .zero, + Duration cooldownDuration = .zero, + bool rethrowErrors = false, +}) { + final c = AsyncButtonController() + ..attach( + onPressed: onPressed, + successDuration: successDuration, + errorDuration: errorDuration, + cooldownDuration: cooldownDuration, + rethrowErrors: rethrowErrors, + ); + addTearDown(c.dispose); + return c; +} + +/// Builder that renders a [TextButton] driven by the AsyncButton callback. +Widget textBuilder(_, Widget child, AsyncCallback? cb, _) { + return TextButton(onPressed: cb, child: child); +} + +/// `checks`-style assertions for [Finder]. +extension FinderChecks on Subject { + void findsOne() => has((f) => f.evaluate().length, 'matches').equals(1); + void findsNone() => has((f) => f.evaluate(), 'matches').isEmpty(); + void findsMany([int min = 1]) => + has((f) => f.evaluate().length, 'matches').isGreaterOrEqual(min); +} + +/// `checks`-style assertions for [AsyncButtonController]. +extension AsyncButtonControllerChecks on Subject { + void hasStatus(AsyncButtonStatus expected) => + has((c) => c.value, 'value').equals(expected); + void isIdle() => hasStatus(const .idle()); + void isLoading() => hasStatus(const .loading()); + void isSuccess() => hasStatus(const .success()); +} diff --git a/test/async_button_builder_test.dart b/test/async_button_builder_test.dart deleted file mode 100644 index 3b8b7af..0000000 --- a/test/async_button_builder_test.dart +++ /dev/null @@ -1,176 +0,0 @@ -import 'package:async_button_builder/async_button_builder.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - testWidgets('displays child text', (tester) async { - await tester.pumpWidget(MaterialApp( - home: AsyncButtonBuilder( - onPressed: () async { - await Future.delayed(const Duration(seconds: 1)); - }, - builder: (context, child, callback, state) { - return TextButton(onPressed: callback, child: child); - }, - child: const Text('click me'), - ), - )); - expect(find.text('click me'), findsOneWidget); - }); - - testWidgets('shows loading widget', (tester) async { - await tester.pumpWidget(MaterialApp( - home: AsyncButtonBuilder( - loadingWidget: const Text('loading'), - onPressed: () async { - await Future.delayed(const Duration(seconds: 1)); - }, - builder: (context, child, callback, state) { - return TextButton(onPressed: callback, child: child); - }, - child: const Text('click me'), - ), - )); - - await tester.tap(find.byType(TextButton)); - - // 1/10 of a second later, loading should be showing - await tester.pump(const Duration(milliseconds: 100)); - - expect(find.text('loading'), findsOneWidget); - - // Let the widget continue to settle otherwise I won't dispose timers - // correctly. TODO: Explain why .900 is the magic number - await tester.pumpAndSettle(const Duration(milliseconds: 900)); - }); - - testWidgets('shows error widget', (tester) async { - await tester.pumpWidget(MaterialApp( - home: AsyncButtonBuilder( - errorWidget: const Text('error'), - onPressed: () async => throw Exception(), - builder: (context, child, callback, state) { - return TextButton(onPressed: callback, child: child); - }, - child: const Text('click me'), - ), - )); - - await tester.tap(find.byType(TextButton)); - await tester.pump(const Duration(milliseconds: 100)); - - expect(find.text('error'), findsOneWidget); - }); - - // testWidgets('error notification bubbles up', (tester) async { - // var idleCount = 0; - // var loadingCount = 0; - // var successCount = 0; - // var errorCount = 0; - - // await tester.pumpWidget(MaterialApp( - // home: NotificationListener( - // onNotification: (notification) { - // tester.printToConsole(notification.buttonState.toString()); - // switch (notification.buttonState) { - // case AsyncButtonStateIdle(): - // idleCount += 1; - // case AsyncButtonStateLoading(): - // loadingCount += 1; - // case AsyncButtonStateSuccess(): - // successCount += 1; - // case AsyncButtonStateError(): - // errorCount += 1; - // } - // return true; - // }, - // child: AsyncButtonBuilder( - // errorDuration: const Duration(milliseconds: 100), - // errorWidget: const Text('error'), - // notifications: true, - // onPressed: () async => throw Exception(), - // builder: (context, child, callback, state) { - // return TextButton(onPressed: callback, child: child); - // }, - // child: const Text('click me'), - // ), - // ), - // )); - // final button = - // find.byType(TextButton).evaluate().first.widget as TextButton; - - // expect( - // button.onPressed!.call, - // throwsA(isA()), - // ); - - // await tester.pump(const Duration(milliseconds: 200)); - - // expect(errorCount, 1); - // expect(idleCount, 1); - // expect(loadingCount, 1); - // expect(successCount, 0); - // }); - - testWidgets('Returns to child widget', (tester) async { - await tester.pumpWidget(MaterialApp( - home: AsyncButtonBuilder( - loadingWidget: const Text('loading'), - onPressed: () async { - await Future.delayed(const Duration(seconds: 1)); - }, - builder: (context, child, callback, state) { - return TextButton(onPressed: callback, child: child); - }, - child: const Text('click me'), - ), - )); - - await tester.tap(find.byType(TextButton)); - await tester.pump(const Duration(milliseconds: 1000)); - - expect(find.text('loading'), findsNothing); - expect(find.text('click me'), findsOneWidget); - }); - - // TODO: Make it work on dropdown buttons - // AsyncButtonBuilder( - // child: Icon(Icons.arrow_upward), - // onPressed: (newValue) async { - // final oldValue = dropdownValue; - - // setState(() { - // dropdownValue = newValue; - // }); - - // await Future.delayed(Duration(seconds: 1)); - - // try { - // if (Random().nextBool()) { - // throw 'yikes'; - // } - // } catch (error) { - // setState(() { - // dropdownValue = oldValue; - // }); - // } - // }, - // builder: (context, child, callback, _) { - // return DropdownButton( - // // icon: SizedBox( - // // height: 16.0, - // // width: 16.0, - // // child: CircularProgressIndicator(), - // // ), - // onChanged: callback, - // items: ['one', 'two', 'three'] - // .map((value) => DropdownMenuItem( - // child: Text(value), - // value: value, - // )) - // .toList(), - // value: dropdownValue, - // ); - // }, - // ), -} diff --git a/test/async_button_controller_test.dart b/test/async_button_controller_test.dart new file mode 100644 index 0000000..9b402a7 --- /dev/null +++ b/test/async_button_controller_test.dart @@ -0,0 +1,224 @@ +import 'dart:async'; + +import 'package:checks/checks.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_async_button/material_async_button.dart'; + +import '_helpers.dart'; + +/// Subscribes a status-recording listener to [c]; returns the recording list. +List recordStatuses(AsyncButtonController c) { + final out = []; + c.addListener(() => out.add(c.value)); + return out; +} + +void main() { + group('AsyncButtonController basics', () { + test('starts idle by default with no onPressed', () { + final c = AsyncButtonController(); + addTearDown(c.dispose); + check(c) + ..isIdle() + ..has((it) => it.canTrigger, 'canTrigger').isFalse(); + }); + + test('honors initial status', () { + final c = AsyncButtonController(const .loading()); + addTearDown(c.dispose); + check(c).isLoading(); + }); + + test('trigger no-ops when no onPressed is attached', () async { + final c = AsyncButtonController(); + addTearDown(c.dispose); + await c.trigger(); + check(c).isIdle(); + }); + }); + + group('AsyncButtonController forced transitions', () { + test('reset returns to idle and cancels pending display', () async { + final c = attachedController( + onPressed: () async {}, + successDuration: const Duration(seconds: 5), + ); + await c.trigger(); + check(c).isSuccess(); + c.reset(); + check(c).isIdle(); + }); + + test('invalidate with zero duration returns straight to idle', () { + final c = attachedController()..invalidate('bad'); + check(c).isIdle(); + }); + + test('invalidate holds error until errorDuration elapses', () async { + final c = attachedController( + errorDuration: const Duration(milliseconds: 50), + )..invalidate('bad'); + check(c.value) + .isA() + .has((f) => f.error, 'error') + .equals('bad'); + await Future.delayed(const Duration(milliseconds: 70)); + check(c).isIdle(); + }); + + test('markSuccess forces success', () { + final c = attachedController( + successDuration: const Duration(seconds: 5), + )..markSuccess(); + check(c).isSuccess(); + }); + }); + + group('AsyncButtonController.trigger', () { + test('successful run traces loading -> success -> idle', () async { + final c = attachedController( + onPressed: () => Future.delayed(const Duration(milliseconds: 20)), + successDuration: const Duration(milliseconds: 20), + ); + final transitions = recordStatuses(c); + await c.trigger(); + await Future.delayed(const Duration(milliseconds: 50)); + check(transitions.map((s) => s.runtimeType).toList()) + ..contains(AsyncButtonStatusLoading) + ..contains(AsyncButtonStatusSuccess) + ..contains(AsyncButtonStatusIdle); + }); + + test('failing run traces loading -> error -> idle', () async { + final c = attachedController( + onPressed: () async => throw StateError('oops'), + errorDuration: const Duration(milliseconds: 20), + ); + final transitions = recordStatuses(c); + await c.trigger(); + await Future.delayed(const Duration(milliseconds: 50)); + check(transitions.map((s) => s.runtimeType).toList()) + ..contains(AsyncButtonStatusLoading) + ..contains(AsyncButtonStatusError) + ..contains(AsyncButtonStatusIdle); + }); + + test('error variant carries the thrown error and stack trace', () async { + final c = attachedController( + onPressed: () async => throw StateError('oops'), + errorDuration: const Duration(milliseconds: 50), + ); + await c.trigger(); + check(c.value) + .isA() + .has((f) => f.error, 'error') + .isA(); + check(c.value) + .isA() + .has((f) => f.stackTrace, 'stackTrace') + .isNotNull(); + await Future.delayed(const Duration(milliseconds: 70)); + check(c).isIdle(); + }); + + test('rethrowErrors=true bubbles the error to the caller', () async { + final c = attachedController( + onPressed: () async => throw StateError('oops'), + rethrowErrors: true, + ); + await check(c.trigger()).throws(); + }); + + test('cooldown keeps canTrigger false after returning to idle', () async { + final c = attachedController( + onPressed: () async {}, + cooldownDuration: const Duration(milliseconds: 40), + ); + await c.trigger(); + check(c) + ..isIdle() + ..has((it) => it.isInCooldown, 'isInCooldown').isTrue() + ..has((it) => it.canTrigger, 'canTrigger').isFalse(); + await Future.delayed(const Duration(milliseconds: 60)); + check(c) + ..has((it) => it.isInCooldown, 'isInCooldown').isFalse() + ..has((it) => it.canTrigger, 'canTrigger').isTrue(); + }); + + test('dispose does not throw for pending timers', () async { + AsyncButtonController() + ..attach( + onPressed: null, + successDuration: const Duration(seconds: 5), + errorDuration: .zero, + cooldownDuration: .zero, + rethrowErrors: false, + ) + ..markSuccess() + ..dispose(); + await Future.delayed(const Duration(milliseconds: 10)); + }); + }); + + group('AsyncButtonController concurrency', () { + test('trigger is a no-op while already loading', () async { + var calls = 0; + final completer = Completer(); + final c = attachedController( + onPressed: () async { + calls++; + await completer.future; + }, + ); + final f1 = c.trigger(); + final f2 = c.trigger(); + check(c).isLoading(); + completer.complete(); + await Future.wait([f1, f2]); + check(calls).equals(1); + }); + + test('reset mid-onPressed stops the success transition', () async { + final completer = Completer(); + final c = attachedController(onPressed: () => completer.future); + final f = c.trigger(); + check(c).isLoading(); + c.reset(); + completer.complete(); + await f; + check(c).isIdle(); + }); + }); + + group('AsyncButtonController interop', () { + testWidgets('exposes a ValueListenable', (tester) async { + final c = attachedController( + successDuration: const Duration(seconds: 5), + ); + + await tester.pumpWidget( + MaterialApp( + home: ValueListenableBuilder( + valueListenable: c, + builder: (context, status, child) => Text( + switch (status) { + AsyncButtonStatusIdle() => 'idle', + AsyncButtonStatusLoading() => 'loading', + AsyncButtonStatusSuccess() => 'success', + AsyncButtonStatusError() => 'error', + }, + textDirection: .ltr, + ), + ), + ), + ); + check(find.text('idle')).findsOne(); + + c.markSuccess(); + await tester.pump(); + check(find.text('success')).findsOne(); + c.reset(); + }); + }); +} diff --git a/test/async_button_test.dart b/test/async_button_test.dart new file mode 100644 index 0000000..757359f --- /dev/null +++ b/test/async_button_test.dart @@ -0,0 +1,401 @@ +import 'package:checks/checks.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_async_button/material_async_button.dart'; + +import '_helpers.dart'; + +void main() { + group('AsyncButton rendering', () { + testWidgets('shows child in idle state', (tester) async { + await tester.pumpWidget( + pumpHost( + AsyncButton( + onPressed: () async {}, + builder: textBuilder, + child: const Text('hello'), + ), + ), + ); + check(find.text('hello')).findsOne(); + }); + + testWidgets('falls back to built-in spinner when no loadingChild', ( + tester, + ) async { + final (:onPressed, :completer) = pendingPress(); + await tester.pumpWidget( + pumpHost( + AsyncButton( + onPressed: onPressed, + builder: textBuilder, + child: const Text('go'), + ), + ), + ); + await tester.tap(find.byType(TextButton)); + await tester.pump(); + check(find.byType(CircularProgressIndicator)).findsOne(); + completer.complete(); + await tester.pumpAndSettle(); + }); + + testWidgets('uses per-widget loadingChild when given', (tester) async { + final (:onPressed, :completer) = pendingPress(); + await tester.pumpWidget( + pumpHost( + AsyncButton( + onPressed: onPressed, + loadingChild: const Text('spinning'), + builder: textBuilder, + child: const Text('go'), + ), + ), + ); + await tester.tap(find.byType(TextButton)); + await tester.pump(); + check(find.text('spinning')).findsOne(); + completer.complete(); + await tester.pumpAndSettle(); + }); + + testWidgets('theme loadingChild used when no per-widget override', ( + tester, + ) async { + final (:onPressed, :completer) = pendingPress(); + await tester.pumpWidget( + pumpHost( + AsyncButton( + onPressed: onPressed, + builder: textBuilder, + child: const Text('go'), + ), + theme: ThemeData( + extensions: const [ + AsyncButtonTheme(loadingChild: Text('themed-loading')), + ], + ), + ), + ); + await tester.tap(find.byType(TextButton)); + await tester.pump(); + check(find.text('themed-loading')).findsOne(); + completer.complete(); + await tester.pumpAndSettle(); + }); + + testWidgets('widget loadingChild beats theme', (tester) async { + final (:onPressed, :completer) = pendingPress(); + await tester.pumpWidget( + pumpHost( + AsyncButton( + onPressed: onPressed, + loadingChild: const Text('widget'), + builder: textBuilder, + child: const Text('go'), + ), + theme: ThemeData( + extensions: const [ + AsyncButtonTheme(loadingChild: Text('themed')), + ], + ), + ), + ); + await tester.tap(find.byType(TextButton)); + await tester.pump(); + check(find.text('widget')).findsOne(); + check(find.text('themed')).findsNone(); + completer.complete(); + await tester.pumpAndSettle(); + }); + }); + + group('AsyncButton transitions', () { + testWidgets('returns to child after success with zero display duration', ( + tester, + ) async { + await tester.pumpWidget( + pumpHost( + AsyncButton( + onPressed: () async {}, + builder: textBuilder, + child: const Text('label'), + ), + ), + ); + await tester.tap(find.byType(TextButton)); + await tester.pumpAndSettle(); + check(find.text('label')).findsOne(); + }); + + testWidgets('shows successChild for successDisplayDuration', ( + tester, + ) async { + await tester.pumpWidget( + pumpHost( + AsyncButton( + onPressed: () async {}, + successChild: const Text('done!'), + successDisplayDuration: const Duration(milliseconds: 200), + builder: textBuilder, + child: const Text('label'), + ), + ), + ); + await tester.tap(find.byType(TextButton)); + await tester.pump(); + await tester.pump(); + check(find.text('done!')).findsOne(); + await tester.pump(const Duration(milliseconds: 250)); + await tester.pumpAndSettle(); + check(find.text('done!')).findsNone(); + check(find.text('label')).findsOne(); + }); + + testWidgets('shows errorChild during error status', (tester) async { + await tester.pumpWidget( + pumpHost( + AsyncButton( + onPressed: () async => throw StateError('boom'), + errorChild: const Text('errored'), + errorDisplayDuration: const Duration(milliseconds: 100), + builder: textBuilder, + child: const Text('label'), + ), + ), + ); + await tester.tap(find.byType(TextButton)); + await tester.pump(); + await tester.pump(); + check(find.text('errored')).findsOne(); + await tester.pump(const Duration(milliseconds: 200)); + await tester.pumpAndSettle(); + }); + + testWidgets('onError carries the thrown error payload', (tester) async { + Object? captured; + await tester.pumpWidget( + pumpHost( + AsyncButton( + onPressed: () async => throw StateError('x'), + onError: (e, _) => captured = e, + builder: textBuilder, + child: const Text('label'), + ), + ), + ); + await tester.tap(find.byType(TextButton)); + await tester.pumpAndSettle(); + check(captured).isA(); + }); + }); + + group('AsyncButton callbacks', () { + testWidgets('callback null when disabled', (tester) async { + await tester.pumpWidget( + pumpHost( + AsyncButton( + onPressed: () async {}, + disabled: true, + builder: textBuilder, + child: const Text('label'), + ), + ), + ); + final btn = tester.widget(find.byType(TextButton)); + check(btn.onPressed).isNull(); + }); + + testWidgets('callback null when onPressed is null', (tester) async { + await tester.pumpWidget( + pumpHost( + const AsyncButton( + onPressed: null, + builder: textBuilder, + child: Text('label'), + ), + ), + ); + final btn = tester.widget(find.byType(TextButton)); + check(btn.onPressed).isNull(); + }); + + testWidgets('onSuccess and onStateChanged fire', (tester) async { + var successCount = 0; + final statuses = []; + await tester.pumpWidget( + pumpHost( + AsyncButton( + onPressed: () async {}, + onSuccess: () => successCount++, + onStateChanged: statuses.add, + builder: textBuilder, + child: const Text('label'), + ), + ), + ); + await tester.tap(find.byType(TextButton)); + await tester.pumpAndSettle(); + check(successCount).equals(1); + check(statuses.map((s) => s.runtimeType).toList()) + ..contains(AsyncButtonStatusLoading) + ..contains(AsyncButtonStatusSuccess) + ..contains(AsyncButtonStatusIdle); + }); + + testWidgets('onError fires with the thrown error', (tester) async { + Object? captured; + await tester.pumpWidget( + pumpHost( + AsyncButton( + onPressed: () async => throw StateError('x'), + onError: (e, _) => captured = e, + builder: textBuilder, + child: const Text('label'), + ), + ), + ); + await tester.tap(find.byType(TextButton)); + await tester.pumpAndSettle(); + check(captured).isA(); + }); + + testWidgets('confirmBeforePress can cancel the press', (tester) async { + var ran = 0; + await tester.pumpWidget( + pumpHost( + AsyncButton( + onPressed: () async => ran++, + confirmBeforePress: (_) async => false, + builder: textBuilder, + child: const Text('label'), + ), + ), + ); + await tester.tap(find.byType(TextButton)); + await tester.pumpAndSettle(); + check(ran).equals(0); + }); + }); + + group('AsyncButton external control', () { + testWidgets('controller.trigger() runs onPressed', (tester) async { + final controller = AsyncButtonController(); + addTearDown(controller.dispose); + var ran = 0; + await tester.pumpWidget( + pumpHost( + AsyncButton( + controller: controller, + onPressed: () async => ran++, + builder: textBuilder, + child: const Text('label'), + ), + ), + ); + await controller.trigger(); + await tester.pumpAndSettle(); + check(ran).equals(1); + }); + + testWidgets('controller.invalidate flips to error', (tester) async { + final controller = AsyncButtonController(); + addTearDown(controller.dispose); + await tester.pumpWidget( + pumpHost( + AsyncButton( + controller: controller, + onPressed: () async {}, + errorChild: const Text('errored'), + errorDisplayDuration: const Duration(milliseconds: 100), + builder: textBuilder, + child: const Text('label'), + ), + ), + ); + controller.invalidate('bad'); + await tester.pump(); + check(find.text('errored')).findsOne(); + await tester.pump(const Duration(milliseconds: 200)); + await tester.pumpAndSettle(); + }); + + testWidgets('controller.reset clears mid-display', (tester) async { + final controller = AsyncButtonController(); + addTearDown(controller.dispose); + await tester.pumpWidget( + pumpHost( + AsyncButton( + controller: controller, + onPressed: () async {}, + successChild: const Text('yay'), + successDisplayDuration: const Duration(seconds: 5), + builder: textBuilder, + child: const Text('label'), + ), + ), + ); + controller.markSuccess(); + await tester.pump(); + check(find.text('yay')).findsOne(); + controller.reset(); + await tester.pump(); + check(find.text('label')).findsOne(); + }); + + testWidgets( + 'swapping the external controller transfers listening without leak', + (tester) async { + final a = AsyncButtonController(); + final b = AsyncButtonController(); + addTearDown(() { + a.dispose(); + b.dispose(); + }); + AsyncButton button(AsyncButtonController c) => AsyncButton( + controller: c, + onPressed: () async {}, + successChild: const Text('done'), + successDisplayDuration: const Duration(milliseconds: 100), + builder: textBuilder, + child: const Text('child'), + ); + await tester.pumpWidget(pumpHost(button(a))); + await tester.pumpWidget(pumpHost(button(b))); + a.markSuccess(); + await tester.pump(); + check(find.text('done')).findsNone(); + b.markSuccess(); + await tester.pump(); + check(find.text('done')).findsOne(); + await tester.pump(const Duration(milliseconds: 200)); + await tester.pumpAndSettle(); + }, + ); + }); + + group('AsyncButton timer hygiene', () { + testWidgets('invalidate then markSuccess does not race', (tester) async { + final controller = AsyncButtonController(); + addTearDown(controller.dispose); + await tester.pumpWidget( + pumpHost( + AsyncButton( + controller: controller, + onPressed: () async {}, + errorChild: const Text('e'), + errorDisplayDuration: const Duration(milliseconds: 200), + builder: textBuilder, + child: const Text('child'), + ), + ), + ); + controller.invalidate('1'); + await tester.pump(); + controller.markSuccess(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 250)); + check(find.text('child')).findsOne(); + }); + }); +} diff --git a/test/elevated_async_button_test.dart b/test/elevated_async_button_test.dart new file mode 100644 index 0000000..ec5237c --- /dev/null +++ b/test/elevated_async_button_test.dart @@ -0,0 +1,89 @@ +import 'package:checks/checks.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_async_button/material_async_button.dart'; + +import '_helpers.dart'; + +void main() { + group('ElevatedAsyncButton', () { + testWidgets('renders an ElevatedButton with the child', (tester) async { + await tester.pumpWidget( + pumpHost( + ElevatedAsyncButton( + onPressed: () async {}, + child: const Text('go'), + ), + ), + ); + check(find.byType(ElevatedButton)).findsOne(); + check(find.text('go')).findsOne(); + }); + + testWidgets('shows loading then returns to idle', (tester) async { + final (:onPressed, :completer) = pendingPress(); + await tester.pumpWidget( + pumpHost( + ElevatedAsyncButton( + onPressed: onPressed, + child: const Text('go'), + ), + ), + ); + await tester.tap(find.byType(ElevatedButton)); + await tester.pump(); + check(find.byType(CircularProgressIndicator)).findsOne(); + completer.complete(); + await tester.pumpAndSettle(); + check(find.text('go')).findsOne(); + }); + + testWidgets('is disabled when onPressed is null', (tester) async { + await tester.pumpWidget( + pumpHost( + const ElevatedAsyncButton(onPressed: null, child: Text('go')), + ), + ); + final btn = tester.widget(find.byType(ElevatedButton)); + check(btn.onPressed).isNull(); + }); + + testWidgets('is disabled when disabled=true', (tester) async { + await tester.pumpWidget( + pumpHost( + ElevatedAsyncButton( + onPressed: () async {}, + disabled: true, + child: const Text('go'), + ), + ), + ); + final btn = tester.widget(find.byType(ElevatedButton)); + check(btn.onPressed).isNull(); + }); + }); + + group('ElevatedAsyncButton.icon', () { + testWidgets('icon stays put while the label animates', (tester) async { + final (:onPressed, :completer) = pendingPress(); + await tester.pumpWidget( + pumpHost( + ElevatedAsyncButton.icon( + onPressed: onPressed, + icon: const Icon(Icons.send), + label: const Text('send'), + ), + ), + ); + check(find.byIcon(Icons.send)).findsOne(); + check(find.text('send')).findsOne(); + await tester.tap(find.byType(ElevatedButton)); + await tester.pump(); + check(find.byIcon(Icons.send)).findsOne(); + check(find.byType(CircularProgressIndicator)).findsOne(); + completer.complete(); + await tester.pumpAndSettle(); + check(find.text('send')).findsOne(); + }); + }); +} diff --git a/test/filled_async_button_test.dart b/test/filled_async_button_test.dart new file mode 100644 index 0000000..eefa1e5 --- /dev/null +++ b/test/filled_async_button_test.dart @@ -0,0 +1,68 @@ +import 'package:checks/checks.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_async_button/material_async_button.dart'; + +import '_helpers.dart'; + +void main() { + group('FilledAsyncButton', () { + testWidgets('renders FilledButton', (tester) async { + await tester.pumpWidget( + pumpHost( + FilledAsyncButton( + onPressed: () async {}, + child: const Text('go'), + ), + ), + ); + check(find.byType(FilledButton)).findsOne(); + }); + + testWidgets('.tonal renders FilledButton in tonal style', (tester) async { + await tester.pumpWidget( + pumpHost( + FilledAsyncButton.tonal( + onPressed: () async {}, + child: const Text('go'), + ), + ), + ); + check(find.byType(FilledButton)).findsOne(); + }); + + testWidgets('.icon swaps label during loading', (tester) async { + final (:onPressed, :completer) = pendingPress(); + await tester.pumpWidget( + pumpHost( + FilledAsyncButton.icon( + onPressed: onPressed, + icon: const Icon(Icons.save), + label: const Text('save'), + ), + ), + ); + await tester.tap(find.byType(FilledButton)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 250)); + check(find.byIcon(Icons.save)).findsOne(); + check(find.text('save')).findsNone(); + check(find.byType(CircularProgressIndicator)).findsOne(); + completer.complete(); + await tester.pumpAndSettle(); + }); + + testWidgets('.tonalIcon renders', (tester) async { + await tester.pumpWidget( + pumpHost( + FilledAsyncButton.tonalIcon( + onPressed: () async {}, + icon: const Icon(Icons.save), + label: const Text('save'), + ), + ), + ); + check(find.byType(FilledButton)).findsOne(); + }); + }); +} diff --git a/test/icon_async_button_test.dart b/test/icon_async_button_test.dart new file mode 100644 index 0000000..2998f13 --- /dev/null +++ b/test/icon_async_button_test.dart @@ -0,0 +1,64 @@ +import 'package:checks/checks.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_async_button/material_async_button.dart'; + +import '_helpers.dart'; + +void main() { + group('IconAsyncButton', () { + testWidgets('renders IconButton with the icon', (tester) async { + await tester.pumpWidget( + pumpHost( + IconAsyncButton( + onPressed: () async {}, + icon: const Icon(Icons.refresh), + ), + ), + ); + check(find.byIcon(Icons.refresh)).findsOne(); + check(find.byType(IconButton)).findsOne(); + }); + + testWidgets('filled, filledTonal, outlined variants all render', ( + tester, + ) async { + final ctors = [ + () => IconAsyncButton.filled( + onPressed: () async {}, + icon: const Icon(Icons.add), + ), + () => IconAsyncButton.filledTonal( + onPressed: () async {}, + icon: const Icon(Icons.add), + ), + () => IconAsyncButton.outlined( + onPressed: () async {}, + icon: const Icon(Icons.add), + ), + ]; + for (final ctor in ctors) { + await tester.pumpWidget(pumpHost(ctor())); + check(find.byType(IconButton)).findsOne(); + } + }); + + testWidgets('swaps icon for loading widget during press', (tester) async { + final (:onPressed, :completer) = pendingPress(); + await tester.pumpWidget( + pumpHost( + IconAsyncButton( + onPressed: onPressed, + icon: const Icon(Icons.refresh), + ), + ), + ); + await tester.tap(find.byType(IconButton)); + await tester.pump(); + check(find.byType(CircularProgressIndicator)).findsOne(); + completer.complete(); + await tester.pumpAndSettle(); + check(find.byIcon(Icons.refresh)).findsOne(); + }); + }); +} diff --git a/test/material_async_button_theme_test.dart b/test/material_async_button_theme_test.dart new file mode 100644 index 0000000..12fdb9b --- /dev/null +++ b/test/material_async_button_theme_test.dart @@ -0,0 +1,167 @@ +import 'package:checks/checks.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_async_button/material_async_button.dart'; + +void main() { + group('AsyncButtonTheme', () { + test('empty default has all null fields', () { + const t = AsyncButtonTheme.empty; + check(t) + ..has((it) => it.loadingChild, 'loadingChild').isNull() + ..has((it) => it.successChild, 'successChild').isNull() + ..has((it) => it.errorChild, 'errorChild').isNull() + ..has((it) => it.switchDuration, 'switchDuration').isNull() + ..has((it) => it.hapticOn, 'hapticOn').isNull(); + }); + + test('material() supplies opinionated baseline', () { + final t = AsyncButtonTheme.material(); + check(t) + ..has((it) => it.loadingChild, 'loadingChild').isNotNull() + ..has((it) => it.successChild, 'successChild').isNotNull() + ..has((it) => it.errorChild, 'errorChild').isNotNull() + ..has( + (it) => it.switchDuration, + 'switchDuration', + ).equals(const Duration(milliseconds: 200)) + ..has( + (it) => it.successDisplayDuration, + 'successDisplayDuration', + ).equals(const Duration(milliseconds: 800)) + ..has( + (it) => it.errorDisplayDuration, + 'errorDisplayDuration', + ).equals(const Duration(milliseconds: 800)) + ..has((it) => it.animateSize, 'animateSize').equals(true) + ..has((it) => it.hapticOn, 'hapticOn').equals(HapticOn.both) + ..has((it) => it.announceSemantics, 'announceSemantics').equals(true); + }); + + test('copyWith overrides only specified fields', () { + final base = AsyncButtonTheme.material(); + final overridden = base.copyWith( + switchDuration: const Duration(milliseconds: 500), + ); + check(overridden) + ..has( + (it) => it.switchDuration, + 'switchDuration', + ).equals(const Duration(milliseconds: 500)) + ..has( + (it) => it.successDisplayDuration, + 'successDisplayDuration', + ).equals(base.successDisplayDuration) + ..has((it) => it.hapticOn, 'hapticOn').equals(base.hapticOn); + }); + + test('lerp snaps non-numeric fields and interpolates durations', () { + const a = AsyncButtonTheme( + switchDuration: Duration(milliseconds: 100), + successDisplayDuration: Duration(milliseconds: 200), + hapticOn: HapticOn.success, + ); + const b = AsyncButtonTheme( + switchDuration: Duration(milliseconds: 300), + successDisplayDuration: Duration(milliseconds: 600), + hapticOn: HapticOn.error, + ); + final mid = a.lerp(b, 0.5); + check(mid) + ..has( + (it) => it.switchDuration, + 'switchDuration', + ).equals(const Duration(milliseconds: 200)) + ..has( + (it) => it.successDisplayDuration, + 'successDisplayDuration', + ).equals(const Duration(milliseconds: 400)) + ..has((it) => it.hapticOn, 'hapticOn').equals(HapticOn.error); + }); + + test('lerp with non-AsyncButtonTheme returns self', () { + const a = AsyncButtonTheme( + switchDuration: Duration(milliseconds: 100), + ); + check(a.lerp(null, 0.5)) + .has((it) => it.switchDuration, 'switchDuration') + .equals(a.switchDuration); + }); + + testWidgets('of(context) returns the registered extension', (tester) async { + const ext = AsyncButtonTheme( + switchDuration: Duration(milliseconds: 123), + ); + AsyncButtonTheme? captured; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(extensions: const [ext]), + home: Builder( + builder: (ctx) { + captured = AsyncButtonTheme.of(ctx); + return const SizedBox.shrink(); + }, + ), + ), + ); + check(captured) + .isNotNull() + .has((it) => it.switchDuration, 'switchDuration') + .equals(const Duration(milliseconds: 123)); + }); + + testWidgets('of(context) falls back to material defaults when absent', ( + tester, + ) async { + AsyncButtonTheme? captured; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (ctx) { + captured = AsyncButtonTheme.of(ctx); + return const SizedBox.shrink(); + }, + ), + ), + ); + check(captured) + .isNotNull() + .has((it) => it.switchDuration, 'switchDuration') + .equals(const Duration(milliseconds: 200)); + }); + + testWidgets( + 'of(context) returns registered empty extension when explicitly set', + (tester) async { + AsyncButtonTheme? captured; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(extensions: const [AsyncButtonTheme.empty]), + home: Builder( + builder: (ctx) { + captured = AsyncButtonTheme.of(ctx); + return const SizedBox.shrink(); + }, + ), + ), + ); + check( + captured, + ).isNotNull().has((it) => it.switchDuration, 'switchDuration').isNull(); + }, + ); + + test('equality is value-based', () { + const a = AsyncButtonTheme( + switchDuration: Duration(milliseconds: 100), + hapticOn: HapticOn.both, + ); + const b = AsyncButtonTheme( + switchDuration: Duration(milliseconds: 100), + hapticOn: HapticOn.both, + ); + check(a).equals(b); + check(a.hashCode).equals(b.hashCode); + }); + }); +} diff --git a/test/outlined_async_button_test.dart b/test/outlined_async_button_test.dart new file mode 100644 index 0000000..370a1b6 --- /dev/null +++ b/test/outlined_async_button_test.dart @@ -0,0 +1,54 @@ +import 'package:checks/checks.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_async_button/material_async_button.dart'; + +import '_helpers.dart'; + +void main() { + group('OutlinedAsyncButton', () { + testWidgets('renders OutlinedButton', (tester) async { + await tester.pumpWidget( + pumpHost( + OutlinedAsyncButton( + onPressed: () async {}, + child: const Text('go'), + ), + ), + ); + check(find.byType(OutlinedButton)).findsOne(); + }); + + testWidgets('.icon renders with icon + label', (tester) async { + await tester.pumpWidget( + pumpHost( + OutlinedAsyncButton.icon( + onPressed: () async {}, + icon: const Icon(Icons.share), + label: const Text('share'), + ), + ), + ); + check(find.byType(OutlinedButton)).findsOne(); + check(find.byIcon(Icons.share)).findsOne(); + check(find.text('share')).findsOne(); + }); + + testWidgets('cycles through loading', (tester) async { + final (:onPressed, :completer) = pendingPress(); + await tester.pumpWidget( + pumpHost( + OutlinedAsyncButton( + onPressed: onPressed, + child: const Text('go'), + ), + ), + ); + await tester.tap(find.byType(OutlinedButton)); + await tester.pump(); + check(find.byType(CircularProgressIndicator)).findsOne(); + completer.complete(); + await tester.pumpAndSettle(); + }); + }); +} diff --git a/test/text_async_button_test.dart b/test/text_async_button_test.dart new file mode 100644 index 0000000..baa0a01 --- /dev/null +++ b/test/text_async_button_test.dart @@ -0,0 +1,56 @@ +import 'package:checks/checks.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_async_button/material_async_button.dart'; + +import '_helpers.dart'; + +void main() { + group('TextAsyncButton', () { + testWidgets('renders TextButton', (tester) async { + await tester.pumpWidget( + pumpHost( + TextAsyncButton( + onPressed: () async {}, + child: const Text('go'), + ), + ), + ); + check(find.byType(TextButton)).findsOne(); + }); + + testWidgets('.icon renders with icon + label', (tester) async { + await tester.pumpWidget( + pumpHost( + TextAsyncButton.icon( + onPressed: () async {}, + icon: const Icon(Icons.copy), + label: const Text('copy'), + ), + ), + ); + check(find.byType(TextButton)).findsOne(); + check(find.byIcon(Icons.copy)).findsOne(); + }); + + testWidgets('controller.trigger() works for "Done" keyboard pattern', ( + tester, + ) async { + final controller = AsyncButtonController(); + addTearDown(controller.dispose); + var ran = 0; + await tester.pumpWidget( + pumpHost( + TextAsyncButton( + controller: controller, + onPressed: () async => ran++, + child: const Text('go'), + ), + ), + ); + await controller.trigger(); + await tester.pumpAndSettle(); + check(ran).equals(1); + }); + }); +} diff --git a/tool/claude/flutter-material-async-button/SKILL.md b/tool/claude/flutter-material-async-button/SKILL.md new file mode 100644 index 0000000..efc886c --- /dev/null +++ b/tool/claude/flutter-material-async-button/SKILL.md @@ -0,0 +1,116 @@ +--- +name: flutter-material-async-button +description: Use this skill when working in a Flutter project that depends on + the material_async_button package, or when the user wants to add + loading/success/error UI to a Material button (ElevatedButton, FilledButton, + OutlinedButton, TextButton, IconButton) whose onPressed is async. Triggers on + "async button", "loading button", or any handler the user writes as + `() async {}` and passes to a Material button. +--- + +# material_async_button + +## Default mapping + +Replace the Material button with its async counterpart whenever `onPressed` +is async. The wrapper handles loading state, the post-press display, and +disables the button while running. + +|Material|Use|Variants| +|--|--|--| +|`ElevatedButton`|`ElevatedAsyncButton`|`.icon`| +|`FilledButton`|`FilledAsyncButton`|`.tonal` `.icon` `.tonalIcon`| +|`OutlinedButton`|`OutlinedAsyncButton`|`.icon`| +|`TextButton`|`TextAsyncButton`|`.icon`| +|`IconButton`|`IconAsyncButton`|`.filled` `.filledTonal` `.outlined`| + +## Minimal use + +```dart +ElevatedAsyncButton( + onPressed: notifier.save, + child: const Text('Save'), +) +``` + +## Theming — do this once + +```dart +ThemeData(extensions: [AsyncButtonTheme.material()]) +``` + +Or, with overrides: + +```dart +ThemeData( + extensions: [ + AsyncButtonTheme( + successChild: const Icon(Icons.check), + errorChild: const Icon(Icons.error_outline), + switchDuration: const Duration(milliseconds: 200), + successDisplayDuration:const Duration(milliseconds: 800), + errorDisplayDuration: const Duration(milliseconds: 800), + animateSize: true, + hapticOn: HapticOn.both, + ), + ], +]) +``` + +Per-button props always win over the theme. + +## External control — `AsyncButtonController` + +Use this for form "Done" keyboard action, parent-owned state, and +cross-widget reactions: + +```dart +final controller = AsyncButtonController(); // dispose like any ChangeNotifier + +TextField( + textInputAction: TextInputAction.done, + onSubmitted: (_) => controller.trigger(), +) +ElevatedAsyncButton( + controller: controller, + onPressed: submit, + child: const Text('Submit'), +) + +controller.trigger(); // run onPressed from outside +controller.invalidate('server rejected'); // force error from outside +controller.markSuccess(); // force success from outside +controller.reset(); // back to idle +``` + +It's a `ValueListenable` — pipe into +`ValueListenableBuilder` for cross-widget reactions. + +## Custom buttons — `AsyncButton` + +Only when no Material wrapper fits: + +```dart +AsyncButton( + onPressed: doWork, + child: const Text('Go'), + builder: (context, child, callback, status) => MyButton( + onTap: callback, + color: switch (status) { + AsyncButtonStatusLoading() => Colors.grey, + AsyncButtonStatusError() => Colors.red, + _ => Colors.indigo, + }, + child: child, + ), +) +``` + +## Don't + +- Don't wrap an already-async-aware button (no nested `…AsyncButton`s). +- Don't pass `disabled: true` to "pause" — pass `null` to `onPressed` instead. +- Don't call `setState` in `onSuccess` / `onError` for state the button + already reflects. +- Don't create a project-wide wrapper widget for default loading/success + spinners — that's exactly what `AsyncButtonTheme` is for.