diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml new file mode 100644 index 0000000..c705867 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -0,0 +1,43 @@ +name: Bug report +description: Something is broken in material_async_button +title: "[bug] " +labels: [bug] +body: + - type: textarea + id: what-happened + attributes: + label: What happened? + description: A clear, concise description of the bug. + validations: + required: true + - type: textarea + id: reproduction + attributes: + label: Minimal reproduction + description: A minimal Dart snippet that reproduces the issue. + render: dart + placeholder: | + // minimal reproduction + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + validations: + required: true + - type: input + id: package-version + attributes: + label: material_async_button version + placeholder: 3.1.0 + validations: + required: true + - type: textarea + id: env + attributes: + label: Environment + description: Output of `flutter doctor -v`. + render: shell + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..0086358 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: true diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml new file mode 100644 index 0000000..e760a02 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -0,0 +1,25 @@ +name: Feature request +description: Suggest a new capability or improvement +title: "[feature] " +labels: [enhancement] +body: + - type: textarea + id: problem + attributes: + label: Problem + description: What problem are you trying to solve? + validations: + required: true + - type: textarea + id: proposed + attributes: + label: Proposed solution + description: How would you like this to work? + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + validations: + required: false diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..f6abe87 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,7 @@ +## Summary + +## Checklist + +- [ ] Tests added or updated +- [ ] `CHANGELOG.md` `## Unreleased` entry +- [ ] Docs / README / skill updated if public API changed diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..0688ce6 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,19 @@ +version: 2 +updates: + - package-ecosystem: pub + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + labels: [dependencies] + - package-ecosystem: pub + directory: /example + schedule: + interval: weekly + open-pull-requests-limit: 5 + labels: [dependencies, example] + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + labels: [dependencies, ci] diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..19c4f9b --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,90 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + format: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: subosito/flutter-action@v2 + with: { channel: stable, cache: true } + - run: git ls-files -z -- '*.dart' | xargs -0 dart format --output=none --set-exit-if-changed + + analyze: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + - uses: subosito/flutter-action@v2 + with: { channel: stable, cache: true } + - run: flutter pub get + - run: flutter analyze --fatal-infos --fatal-warnings + - run: flutter pub get + working-directory: example + - run: flutter analyze --fatal-infos --fatal-warnings + working-directory: example + + test: + name: test (${{ matrix.name }}) + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - { name: floor, channel: stable, version: '3.47.0' } + - { name: stable, channel: stable, version: '' } + - { name: beta, channel: beta, version: '' } + steps: + - uses: actions/checkout@v7 + - uses: subosito/flutter-action@v2 + with: + channel: ${{ matrix.channel }} + flutter-version: ${{ matrix.version }} + cache: true + - run: flutter pub get + - run: flutter test + + downgrade: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v7 + - uses: subosito/flutter-action@v2 + with: { channel: stable, cache: true } + - run: flutter pub downgrade + - run: flutter analyze --fatal-warnings + - run: flutter test + + pana: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + - uses: subosito/flutter-action@v2 + with: { channel: stable, cache: true } + - run: dart pub global activate pana + - run: dart pub global run pana --no-warning --exit-code-threshold 0 + + publish-dry-run: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: subosito/flutter-action@v2 + with: { channel: stable, cache: true } + - run: flutter pub publish --dry-run diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml new file mode 100644 index 0000000..5ba34f2 --- /dev/null +++ b/.github/workflows/publish.yaml @@ -0,0 +1,46 @@ +name: Publish + +on: + # Automated publishing is paused; pub.dev Admin → Automated publishing stays enabled. + # Re-enable by uncommenting the tag trigger below. pub.dev rejects publishes not started + # by a tag push, so a manual workflow_dispatch run cannot publish. + # push: + # tags: + # - 'v[0-9]+.[0-9]+.[0-9]+*' + workflow_dispatch: + +permissions: + id-token: write + contents: read + +jobs: + verify: + name: Verify tag matches pubspec + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Check tag matches pubspec version + run: | + TAG="${GITHUB_REF#refs/tags/v}" + PUBSPEC=$(grep '^version:' pubspec.yaml | awk '{print $2}') + if [ "$TAG" != "$PUBSPEC" ]; then + echo "::error::Tag v$TAG does not match pubspec.yaml version $PUBSPEC" + exit 1 + fi + + publish: + name: Publish to pub.dev + needs: verify + runs-on: ubuntu-latest + environment: pub.dev + permissions: + id-token: write + contents: read + steps: + - uses: actions/checkout@v6 + - uses: dart-lang/setup-dart@v1 + - uses: subosito/flutter-action@v2 + with: + channel: stable + - run: flutter pub get + - run: flutter pub publish --force diff --git a/.gitignore b/.gitignore index 1985397..5c5d214 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,8 @@ *.ipr *.iws .idea/ +.idea +.vscode/ # The .vscode folder contains launch configuration and tasks you configure in # VS Code which you may wish to be included in version control, so this line @@ -60,6 +62,7 @@ build/ **/ios/Flutter/Flutter.framework **/ios/Flutter/Flutter.podspec **/ios/Flutter/Generated.xcconfig +**/ios/Flutter/ephemeral **/ios/Flutter/app.flx **/ios/Flutter/app.zip **/ios/Flutter/flutter_assets/ @@ -72,3 +75,12 @@ build/ !**/ios/**/default.mode2v3 !**/ios/**/default.pbxuser !**/ios/**/default.perspectivev3 + + +.melos_tool/ +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/CHANGELOG.md b/CHANGELOG.md index 9d67c37..d69f430 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,173 @@ -## [0.0.1-nullsafety.0] - 2021-01-11 +# Changelog -Holds basic builder AsyncButtonBuilder. No tests yet. NNBD is supported +## 3.1.0 - 2026-10-04 + +### Added + +- `IconAsyncButton` (all four constructors) forwards `onHover`, `onLongPress` + (dropped while loading) and `statesController` to its `IconButton`. +- `TextAsyncButton.isSemanticButton`, forwarded to `TextButton`. +- `maintainSize` on `AsyncButtonTheme`, `AsyncButton` and every wrapper: the + loading view overlays the invisible idle child, so the button keeps its idle + footprint while loading (`.icon` constructors keep their icon). +- `minLoadingDuration` on `AsyncButtonTheme`, `AsyncButton` and every wrapper: + an anti-flicker floor that holds the loading view at least that long; an + error rethrows once the floor has elapsed. + +### Changed + +- Requires Dart 3.13 / Flutter 3.47 (was Dart 3.10 / Flutter 3.38). +- One `AsyncButtonController` bound to two mounted buttons now fails a debug + assertion (release builds keep last-binder-wins). +- `AsyncButtonController.reset()` abandons the in-flight run: the button is + idle and re-armed at once, and the abandoned run's completion is ignored. +- `onPressed: null` with an `onLongPress` keeps the button enabled for + long-press, as in Flutter. +- The agent skill moved to `skills/material-async-button-usage/` for + `dart run skills@ get` (was `skills/flutter-material-async-button/`). + +### Fixed + +- Loading buttons no longer grow to fill their parent (every non-FAB wrapper + since 2.0.0, and custom `loadingBuilder`s returning `AsyncButtonSpinner`). +- `reset()` mid-flight no longer lets the stale run clear a new run's loading + state. +- Swapping a button's controller mid-flight keeps its loading state: the + incoming controller adopts the run that button is showing. +- An external controller detaches when its button unmounts or is swapped out: + `canTrigger` is `false` and `trigger()` is a no-op. +- `trigger()` after `dispose()` never runs `onPressed`. +- A selected `IconAsyncButton` (`isSelected: true` with a `selectedIcon`) shows + the spinner while loading. +- A collapsed extended FAB (`FloatingActionAsyncButton.extended` with + `isExtended: false`) shows the spinner while loading. +- Every `.icon` / `.tonalIcon` constructor accepts `enabled`. +- The default spinner no longer leaks a `TextPainter` per line-box cache miss. +- The default spinner re-measures after fonts load (e.g. google_fonts): the + line-box cache is cleared on every system font change. + +## 3.0.0 + +### Breaking + +- `AsyncStandardMaterialButton` is now sealed and can no longer be extended + outside the package; compose `AsyncButton` for custom buttons. + +## 2.1.2 + +### Improved + +- **Smarter line-box cache eviction.** The bounded text line-box cache now + evicts only its least-recently-used entry when full, instead of clearing + entirely — cycling through many text styles no longer re-measures every + cached style. +- Added test coverage for `AsyncButtonSpinner.strokeWidth`. + +## 2.1.1 + +### Fixed + +- **Avoid layout cache thrashing.** Replaced the singleton `TextPainter` cache with a bounded Map. This fixes a performance issue where the cache would thrash (and incorrectly re-measure) if multiple buttons with different text styles were rendered in the same frame. +- **Dead code warning.** Fixed a static analysis warning in `AsyncButtonTheme.lerp` by using `covariant` for the `other` parameter. +- Added comprehensive test coverage for `AsyncButtonController` disposal during an active asynchronous operation and external controller swapping. + +## 2.1.0 + +### Added + +- **New `FloatingActionAsyncButton` wrapper.** Adds drop-in async loading states + to `FloatingActionButton` with support for all four core variants: standard, + `.small`, `.large`, and `.extended`. +- **Improved accessibility for screen readers.** Added `semanticsLabel` parameter to + `AsyncButtonSpinner` (defaulting to `'Loading'`) to ensure screen readers + actively announce the progress of asynchronous operations when buttons are clicked. + +## 2.0.2 + +### Fixed + +- **Buttons no longer shrink by a sub-pixel while loading.** The default spinner + now sizes to the label's **line-box height** (the idle content's vertical + extent) rather than the raw `fontSize`, which is shorter than the rendered + line. `.icon` buttons take `max(iconSize, lineBox)`, text-only buttons take the + line box, icon-only buttons keep `iconSize` (unchanged). The line box is + measured with the ambient `TextScaler`, so the loading view also tracks text + scaling. + +## 2.0.1 + +### Fixed + +- **Icon-bearing buttons no longer shrink vertically while loading.** The + default spinner sizes to the button's shape instead of always to the font + size: `iconSize` for `IconAsyncButton`, `max(iconSize, fontSize)` for the + `.icon` constructors (the idle row height), and `fontSize` for text-only + buttons (unchanged). Sizes are read from the button's resolved `IconTheme` / + `DefaultTextStyle`, so a per-widget `iconSize` and theme overrides flow + through. + +## 2.0.0 + +Breaking redesign. The button now does exactly one job — show a spinner while +`onPressed` is in flight — and stays out of error handling, success feedback, +and styling. State collapses to a single `bool` (loading), and the theme becomes +a pure complement to `ButtonStyle`. + +### Breaking + +- **Loading-only: no success or error state.** Removed `AsyncButtonStatus` + entirely (the state is now a plain `bool isLoading`), along with `onSuccess`, + `onError`, `successBuilder`, `errorBuilder`, `feedbackDuration`, + `cooldownDuration`, `markSuccess`, `markError`, `haptic` / `HapticOn`, and + `announce` / `AsyncButtonAnnouncer` / `defaultAsyncButtonAnnouncer`. An + in-button error view is a Material anti-pattern; success is handled by what + your action already does (navigate away, flip the label). Both belong to your + state management, not the button. +- **`onPressed` throws now re-propagate.** When `onPressed` throws, the button + returns to idle and **re-throws** so the error reaches `FlutterError.onError` + / your `runZonedGuarded` zone. `controller.trigger()` rethrows instead of + completing normally. +- **`AsyncButtonController` is now a read-only `ValueListenable`** + (`ChangeNotifier implements ValueListenable`). There is no public + `value` setter — drive it with `trigger()` / `reset()` and observe with + `addListener` / `ValueListenableBuilder`. Builders receive `bool + isLoading` instead of an `AsyncButtonStatus`. +- **`AsyncButtonTheme` carries only `loadingBuilder` + `transitionBuilder`.** + `AsyncButtonTheme.empty` (spinner-only) is the sole baseline; removed + `AsyncButtonTheme.material()`. It complements `ButtonStyle` — no styling. +- **`disabled` flag → `enabled` (defaults to `true`).** Affirmative naming that + pairs with a tear-off `onPressed`. Either `enabled: false` or `onPressed: null` + disables — both also no-op an external `controller.trigger()`. +- **`loadingChild` (a `Widget`) → `loadingBuilder` (a `WidgetBuilder`)** on both + the widgets and `AsyncButtonTheme`. +- **Removed the loading-colour knobs** (`loadingForegroundColor`, + `material(loadingColor:)`). The spinner inherits each button variant's + foreground; recolour a single button via `AsyncButtonSpinner(color: ...)`. + +### Fixed + +- **Loading never disables the button.** It keeps the button's themed enabled + colours — being loading and being *disabled* are different things. The spinner + inherits the enabled foreground for free (no more greyed indicator); taps that + can't run are silently swallowed, and `onLongPress` is gated off while busy. + The button shows the disabled look **only** when explicitly disabled via + `onPressed: null`. +- **`.icon` buttons drop the icon while loading**, rendering the spinner alone. + +### Improved + +- The default `AsyncButtonSpinner` derives its size from the ambient font size, + so it matches the button's label instead of a fixed 16px. +- Guarded against state changes on an unmounted button. +- Corrected the `flutter` SDK constraint. + +## 1.0.1 + +- Docs: use tear-off form (`onPressed: api.save`) in README examples. +- CI: split workflow into format/analyze/test/pana/example, add + tag-triggered pub.dev publish via OIDC trusted publishing. + +## 1.0.0 + +Initial release. Renamed from `async_button_builder` to `material_async_button` +with a redesigned, theme-aware API. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..610bee5 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,11 @@ +# Contributing + +1. `flutter pub get` +2. Run what CI runs: + - `git ls-files -z -- '*.dart' | xargs -0 dart format --output=none --set-exit-if-changed` + - `flutter analyze --fatal-infos --fatal-warnings` + - `flutter test` + - `flutter pub publish --dry-run` +3. Add a line under `## Unreleased` in `CHANGELOG.md` for every user-visible change. + +Releases are published manually by the maintainer. diff --git a/LICENSE b/LICENSE index df4fb86..4c996a4 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2021 RexMa +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 010828c..27d3660 100644 --- a/README.md +++ b/README.md @@ -1,66 +1,233 @@ -# async_button_builder +# material_async_button -A builder that adds loading and disabled states on top of buttons that perform asynchronous. It can be used with most any button or even on top of a custom Material button. It includes fluid animation between states as well using `AnimatedSize`. +[![pub package](https://img.shields.io/pub/v/material_async_button.svg)](https://pub.dev/packages/material_async_button) [![pub points](https://img.shields.io/pub/points/material_async_button)](https://pub.dev/packages/material_async_button/score) [![CI](https://github.com/esenmx/material_async_button/actions/workflows/ci.yaml/badge.svg)](https://github.com/esenmx/material_async_button/actions/workflows/ci.yaml) [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) -

- -

+Drop-in async wrappers for Flutter Material buttons. Adds a **loading** state to +`ElevatedButton`, `FilledButton`, `OutlinedButton`, `TextButton`, `IconButton`, +and `FloatingActionButton` — without forcing you to build a project-wide wrapper +widget. -## Getting Started +```dart +ElevatedAsyncButton( + onPressed: api.save, + child: const Text('Save'), +) +``` + +That's it. The button shows a spinner while `save()` runs and returns to its +label when it completes. + +## Install + +```sh +flutter pub add material_async_button +``` + +## Why + +Most apps end up writing their own `DefaultAsyncButton` wrapper to share a +loading widget and its transition across screens. This package gives you that +wrapper as a [`ThemeExtension`][th] — configure once, every `*AsyncButton` picks +it up; override per button when you need to. + +[th]: https://api.flutter.dev/flutter/material/ThemeExtension-class.html + +## Material wrappers + +| Material | Async counterpart | Variants | +| ---------------------- | --------------------------- | -------------------------------------- | +| `ElevatedButton` | `ElevatedAsyncButton` | `.icon` | +| `FilledButton` | `FilledAsyncButton` | `.tonal`, `.icon`, `.tonalIcon` | +| `OutlinedButton` | `OutlinedAsyncButton` | `.icon` | +| `TextButton` | `TextAsyncButton` | `.icon` | +| `IconButton` | `IconAsyncButton` | `.filled`, `.filledTonal`, `.outlined` | +| `FloatingActionButton` | `FloatingActionAsyncButton` | `.small`, `.large`, `.extended` | + +Every Material constructor is mirrored. All Material parameters (`style`, +`focusNode`, `autofocus`, `clipBehavior`, `statesController`, etc.) are +forwarded verbatim, with two FAB exceptions: `onPressed` is required, and the +default `heroTag` is a package sentinel rather than Flutter's, so no hero flight +runs between a plain and an async FAB. `AsyncButtonTheme` complements +`ButtonStyle` / `ButtonThemeData` — it carries only async behaviour, never +styling. + +## Loading only — by design -Include the package: +The button does one job: show a spinner while `onPressed` is in flight. It has +no success or error state. +- **No error state.** An in-button error view is a Material anti-pattern, and + error handling belongs to your state management. When `onPressed` throws, the + button returns to idle and **re-throws**: a tap's error reaches the + surrounding zone (your `runZonedGuarded`, else + `PlatformDispatcher.instance.onError`) like any other uncaught async error, + and a `controller.trigger()` caller gets a rejected Future. Handle it where it + belongs: + + ```dart + // Typical: your notifier/repository absorbs the failure internally + // (e.g. AsyncValue.guard), so onPressed never throws. + ElevatedAsyncButton( + onPressed: () => ref.read(saveProvider.notifier).save(), + child: const Text('Save'), + ) + + // Or handle it inline and surface it your way: + ElevatedAsyncButton( + onPressed: () async { + try { + await repo.submit(); + } on Exception catch (error) { + messenger.showSnackBar(SnackBar(content: Text('$error'))); + } + }, + child: const Text('Submit'), + ) + ``` + +- **No success state.** Success is handled by what your action already does — + navigate away, flip the label (Save → Unsave), update a list. An in-button + "Saved ✓" is usually redundant. Surface it the same way you surface any state + change. + +## Theming + +`AsyncButtonTheme` is a `ThemeExtension`. Resolution order for any field is +**per-widget value → theme value → built-in fallback**. + +```dart +ThemeData( + extensions: [ + AsyncButtonTheme( + loadingBuilder: (_) => const AsyncButtonSpinner(strokeWidth: 3), + // transitionBuilder: animate every button's swap — see Defaults below. + maintainSize: true, // keep the idle footprint while loading + minLoadingDuration: const Duration(milliseconds: 300), // anti-flicker + ), + ], +) ``` - async_button_builder: + +- `maintainSize` (default `false`) overlays the loading view on the invisible + idle child, so the button keeps its idle size — no width jump. `.icon` + constructors keep their icon visible beside the spinner. +- `minLoadingDuration` (default `Duration.zero`) holds the loading view at + least that long from the tap or `controller.trigger()`, so a fast future + doesn't flash a spinner. An error rethrows once the floor has elapsed. + +Both are also per-widget parameters on every button and on `AsyncButton`. + +With no extension registered, `AsyncButtonTheme.of` falls back to +`AsyncButtonTheme.empty` — the default spinner and nothing else. + +`copyWith` can't reset a field to null — build a new `AsyncButtonTheme` +instead. + +## Custom buttons — `AsyncButton` + +`AsyncButton` is the low-level escape hatch. Use it when none of the Material +wrappers fit. The builder receives whether the button is loading: + +```dart +AsyncButton( + onPressed: doWork, + child: const Text('Go'), + builder: (context, child, callback, isLoading) => MyButton( + onTap: callback, + color: isLoading ? Colors.grey : Colors.indigo, + child: child, + ), +) ``` -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. +## External control + +`AsyncButtonController` is a `ValueListenable` (loading) plus imperative +methods. Use it for **form keyboard "Done"**, parent-owned state, cross-widget +reactions, and tests. ```dart -AsyncButtonBuilder( - child: Text('Click Me'), - onPressed: () async { - await Future.delayed(Duration(seconds: 1)); - }, - builder: (context, child, callback) { - return TextButton( - child: child, - onPressed: callback, - ); - }, -), +final controller = AsyncButtonController(); // dispose like any ChangeNotifier + +TextField( + textInputAction: .done, + onSubmitted: (_) => controller.trigger(), +) +ElevatedAsyncButton( + controller: controller, + onPressed: submit, + child: const Text('Submit'), +) + +controller.trigger(); // run onPressed from outside (rethrows on failure) +controller.reset(); // force back to idle, abandoning the in-flight run +controller.value; // bool — true while loading (ValueListenable) +controller.canTrigger; // bool — true when trigger() would run (not loading, callback attached) ``` -You can also change the loading indicator with anything you prefer using the `loadingWidget` field. By default it uses a `CircularProgressIndicator`. +`reset()` abandons the in-flight run: the button is idle and re-armed at once +(an escape hatch for a hung future), and the abandoned run's completion is +ignored. One controller drives one mounted button — binding it to a second +fails a debug assertion. + +## Defaults + +| State | UI | +| -------- | ------------------------------------------- | +| idle | your `child` | +| loading | `AsyncButtonSpinner` (sized to the label) | + +The label-button `.icon` constructors (`ElevatedAsyncButton.icon`, +`FilledAsyncButton.icon`, etc.) drop the icon while loading and show the spinner +alone, unless `maintainSize` is set — then the icon stays. (`IconAsyncButton` +has no `.icon` variant — it swaps its sole icon for the spinner.) -For a custom button, you can specify properties such as `padding` and `loadingPadding`. +**Loading never disables the button.** Being loading and being *disabled* are +different things — the spinner is the indicator, the button keeps its themed +enabled colours, and taps that can't run are silently swallowed (`onLongPress` +is gated off while busy). The button shows the disabled look **only** when you +disable it explicitly — pass `enabled: false` (defaults to `true`) or +`onPressed: null`. As in Flutter, a label button with `onPressed: null` and an +`onLongPress` stays enabled for long-press; `IconAsyncButton`, like +`IconButton`, ignores `onLongPress` without `onPressed`. Either path also +no-ops an external `controller.trigger()`. +**The swap is instant; the button resizes to fit the loading widget.** To keep +the idle size instead, set `maintainSize: true`. The button does no animation of +its own. To smooth the swap — and the size change when the spinner differs from +the child — pass a `transitionBuilder`. The child is already keyed by loading +state, so an `AnimatedSwitcher` inside an `AnimatedSize` is all it takes: ```dart -Material( - color: Colors.lightBlue, - shape: StadiumBorder(), - child: AsyncButtonBuilder( - valueColor: Colors.white, - padding: const EdgeInsets.symmetric( - horizontal: 16.0, - vertical: 8.0, - ), - loadingPadding: const EdgeInsets.all(8.0), - child: Text( - 'Click Me', - style: TextStyle(color: Colors.white), +ElevatedAsyncButton( + onPressed: api.save, + transitionBuilder: (context, child, isLoading) => AnimatedSize( + duration: const Duration(milliseconds: 200), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: child, ), - onPressed: () async { - await Future.delayed(Duration(seconds: 1)); - }, - builder: (context, child, callback) { - return InkWell( - child: child, - onTap: callback, - ); - }, ), -), + child: const Text('Save'), +) ``` +Set `transitionBuilder` on `AsyncButtonTheme` to animate every button at once. +`AsyncButtonSpinner` is public and inherits the button's foreground — customise +its `color` / `strokeWidth` / `size` and return it from `loadingBuilder`. + +## Agent skill + +This package ships an agent skill in `skills/material-async-button-usage/`. Install it into your project's agent config with: + +```sh +dart run skills@ get --package material_async_button --all +``` + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md). + +## License + +MIT — see [LICENSE](LICENSE). diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..53330fa --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,11 @@ +include: package:very_good_analysis/analysis_options.yaml + +analyzer: + exclude: + - build/** + +linter: + rules: + # Maintainer convention: fire-and-forget is a bare drop; never unawaited(). + unawaited_futures: false + discarded_futures: false diff --git a/example/.gitignore b/example/.gitignore index 0fa6b67..80a9898 100644 --- a/example/.gitignore +++ b/example/.gitignore @@ -44,3 +44,13 @@ app.*.map.json /android/app/debug /android/app/profile /android/app/release + +/pubspec.lock + +# Platform Folders +android/ +ios/ +web/ +macos/ +windows/ +linux/ \ No newline at end of file diff --git a/example/.metadata b/example/.metadata index 36a4614..c24b9a1 100644 --- a/example/.metadata +++ b/example/.metadata @@ -4,7 +4,27 @@ # This file should be version controlled and should not be manually edited. version: - revision: 63062a64432cce03315d6b5196fda7912866eb37 - channel: dev + revision: "00b0c91f06209d9e4a41f71b7a512d6eb3b9c694" + channel: "stable" project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694 + base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694 + - platform: macos + create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694 + base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/example/README.md b/example/README.md index a135626..00bede4 100644 --- a/example/README.md +++ b/example/README.md @@ -1,16 +1,3 @@ -# example +# material_async_button example -A new Flutter project. - -## Getting Started - -This project is a starting point for a Flutter application. - -A few resources to get you started if this is your first Flutter project: - -- [Lab: Write your first Flutter app](https://flutter.dev/docs/get-started/codelab) -- [Cookbook: Useful Flutter samples](https://flutter.dev/docs/cookbook) - -For help getting started with Flutter, view our -[online documentation](https://flutter.dev/docs), which offers tutorials, -samples, guidance on mobile development, and a full API reference. +Platform folders are not tracked: run `flutter create .` once, then `flutter run`. diff --git a/example/analysis_options.yaml b/example/analysis_options.yaml new file mode 100644 index 0000000..f830802 --- /dev/null +++ b/example/analysis_options.yaml @@ -0,0 +1,5 @@ +analyzer: + exclude: + - build/** + - macos/** +include: package:flutter_lints/flutter.yaml diff --git a/example/android/.gitignore b/example/android/.gitignore deleted file mode 100644 index 0a741cb..0000000 --- a/example/android/.gitignore +++ /dev/null @@ -1,11 +0,0 @@ -gradle-wrapper.jar -/.gradle -/captures/ -/gradlew -/gradlew.bat -/local.properties -GeneratedPluginRegistrant.java - -# Remember to never publicly share your keystore. -# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app -key.properties diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle deleted file mode 100644 index 6ca2344..0000000 --- a/example/android/app/build.gradle +++ /dev/null @@ -1,59 +0,0 @@ -def localProperties = new Properties() -def localPropertiesFile = rootProject.file('local.properties') -if (localPropertiesFile.exists()) { - localPropertiesFile.withReader('UTF-8') { reader -> - localProperties.load(reader) - } -} - -def flutterRoot = localProperties.getProperty('flutter.sdk') -if (flutterRoot == null) { - throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") -} - -def flutterVersionCode = localProperties.getProperty('flutter.versionCode') -if (flutterVersionCode == null) { - flutterVersionCode = '1' -} - -def flutterVersionName = localProperties.getProperty('flutter.versionName') -if (flutterVersionName == null) { - flutterVersionName = '1.0' -} - -apply plugin: 'com.android.application' -apply plugin: 'kotlin-android' -apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" - -android { - compileSdkVersion 30 - - sourceSets { - main.java.srcDirs += 'src/main/kotlin' - } - - defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId "com.example.example" - minSdkVersion 16 - targetSdkVersion 30 - versionCode flutterVersionCode.toInteger() - versionName flutterVersionName - } - - buildTypes { - release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig signingConfigs.debug - } - } -} - -flutter { - source '../..' -} - -dependencies { - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" -} diff --git a/example/android/app/src/debug/AndroidManifest.xml b/example/android/app/src/debug/AndroidManifest.xml deleted file mode 100644 index c208884..0000000 --- a/example/android/app/src/debug/AndroidManifest.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml deleted file mode 100644 index 34dd77e..0000000 --- a/example/android/app/src/main/AndroidManifest.xml +++ /dev/null @@ -1,41 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/example/android/app/src/main/kotlin/com/example/example/MainActivity.kt b/example/android/app/src/main/kotlin/com/example/example/MainActivity.kt deleted file mode 100644 index e793a00..0000000 --- a/example/android/app/src/main/kotlin/com/example/example/MainActivity.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.example.example - -import io.flutter.embedding.android.FlutterActivity - -class MainActivity: FlutterActivity() { -} diff --git a/example/android/app/src/main/res/drawable-v21/launch_background.xml b/example/android/app/src/main/res/drawable-v21/launch_background.xml deleted file mode 100644 index f74085f..0000000 --- a/example/android/app/src/main/res/drawable-v21/launch_background.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - diff --git a/example/android/app/src/main/res/drawable/launch_background.xml b/example/android/app/src/main/res/drawable/launch_background.xml deleted file mode 100644 index 304732f..0000000 --- a/example/android/app/src/main/res/drawable/launch_background.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - diff --git a/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png deleted file mode 100644 index db77bb4..0000000 Binary files a/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and /dev/null differ diff --git a/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png deleted file mode 100644 index 17987b7..0000000 Binary files a/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and /dev/null differ diff --git a/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png deleted file mode 100644 index 09d4391..0000000 Binary files a/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and /dev/null differ diff --git a/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png deleted file mode 100644 index d5f1c8d..0000000 Binary files a/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and /dev/null differ diff --git a/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png deleted file mode 100644 index 4d6372e..0000000 Binary files a/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ diff --git a/example/android/app/src/main/res/values-night/styles.xml b/example/android/app/src/main/res/values-night/styles.xml deleted file mode 100644 index 449a9f9..0000000 --- a/example/android/app/src/main/res/values-night/styles.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - diff --git a/example/android/app/src/main/res/values/styles.xml b/example/android/app/src/main/res/values/styles.xml deleted file mode 100644 index d74aa35..0000000 --- a/example/android/app/src/main/res/values/styles.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - diff --git a/example/android/app/src/profile/AndroidManifest.xml b/example/android/app/src/profile/AndroidManifest.xml deleted file mode 100644 index c208884..0000000 --- a/example/android/app/src/profile/AndroidManifest.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - diff --git a/example/android/build.gradle b/example/android/build.gradle deleted file mode 100644 index c505a86..0000000 --- a/example/android/build.gradle +++ /dev/null @@ -1,31 +0,0 @@ -buildscript { - ext.kotlin_version = '1.3.50' - repositories { - google() - jcenter() - } - - dependencies { - classpath 'com.android.tools.build:gradle:4.1.0' - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - } -} - -allprojects { - repositories { - google() - jcenter() - } -} - -rootProject.buildDir = '../build' -subprojects { - project.buildDir = "${rootProject.buildDir}/${project.name}" -} -subprojects { - project.evaluationDependsOn(':app') -} - -task clean(type: Delete) { - delete rootProject.buildDir -} diff --git a/example/android/gradle.properties b/example/android/gradle.properties deleted file mode 100644 index 94adc3a..0000000 --- a/example/android/gradle.properties +++ /dev/null @@ -1,3 +0,0 @@ -org.gradle.jvmargs=-Xmx1536M -android.useAndroidX=true -android.enableJetifier=true diff --git a/example/android/gradle/wrapper/gradle-wrapper.properties b/example/android/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index bc6a58a..0000000 --- a/example/android/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -1,6 +0,0 @@ -#Fri Jun 23 08:50:38 CEST 2017 -distributionBase=GRADLE_USER_HOME -distributionPath=wrapper/dists -zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.7-all.zip diff --git a/example/android/settings.gradle b/example/android/settings.gradle deleted file mode 100644 index 44e62bc..0000000 --- a/example/android/settings.gradle +++ /dev/null @@ -1,11 +0,0 @@ -include ':app' - -def localPropertiesFile = new File(rootProject.projectDir, "local.properties") -def properties = new Properties() - -assert localPropertiesFile.exists() -localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } - -def flutterSdkPath = properties.getProperty("flutter.sdk") -assert flutterSdkPath != null, "flutter.sdk not set in local.properties" -apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" diff --git a/example/integration_test/app_test.dart b/example/integration_test/app_test.dart deleted file mode 100644 index 3e01719..0000000 --- a/example/integration_test/app_test.dart +++ /dev/null @@ -1,36 +0,0 @@ -// This is a basic Flutter integration test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility that Flutter provides. 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:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:integration_test/integration_test.dart'; - -import 'package:example/main.dart' as app; - -void main() => run(_testMain); - -void _testMain() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - app.main(); - - // Trigger a frame. - await tester.pumpAndSettle(); - - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); - - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); - - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); - }); -} diff --git a/example/integration_test/driver.dart b/example/integration_test/driver.dart deleted file mode 100644 index a03bca0..0000000 --- a/example/integration_test/driver.dart +++ /dev/null @@ -1,8 +0,0 @@ -// This file is provided as a convenience for running integration tests via the -// flutter drive command. -// -// flutter drive --driver integration_test/driver.dart --target integration_test/app_test.dart - -import 'package:integration_test/integration_test_driver.dart'; - -Future main() => integrationDriver(); diff --git a/example/ios/.gitignore b/example/ios/.gitignore deleted file mode 100644 index e96ef60..0000000 --- a/example/ios/.gitignore +++ /dev/null @@ -1,32 +0,0 @@ -*.mode1v3 -*.mode2v3 -*.moved-aside -*.pbxuser -*.perspectivev3 -**/*sync/ -.sconsign.dblite -.tags* -**/.vagrant/ -**/DerivedData/ -Icon? -**/Pods/ -**/.symlinks/ -profile -xcuserdata -**/.generated/ -Flutter/App.framework -Flutter/Flutter.framework -Flutter/Flutter.podspec -Flutter/Generated.xcconfig -Flutter/app.flx -Flutter/app.zip -Flutter/flutter_assets/ -Flutter/flutter_export_environment.sh -ServiceDefinitions.json -Runner/GeneratedPluginRegistrant.* - -# Exceptions to above rules. -!default.mode1v3 -!default.mode2v3 -!default.pbxuser -!default.perspectivev3 diff --git a/example/ios/Flutter/AppFrameworkInfo.plist b/example/ios/Flutter/AppFrameworkInfo.plist deleted file mode 100644 index 9367d48..0000000 --- a/example/ios/Flutter/AppFrameworkInfo.plist +++ /dev/null @@ -1,26 +0,0 @@ - - - - - CFBundleDevelopmentRegion - en - CFBundleExecutable - App - CFBundleIdentifier - io.flutter.flutter.app - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - App - CFBundlePackageType - FMWK - CFBundleShortVersionString - 1.0 - CFBundleSignature - ???? - CFBundleVersion - 1.0 - MinimumOSVersion - 8.0 - - diff --git a/example/ios/Flutter/Debug.xcconfig b/example/ios/Flutter/Debug.xcconfig deleted file mode 100644 index e8efba1..0000000 --- a/example/ios/Flutter/Debug.xcconfig +++ /dev/null @@ -1,2 +0,0 @@ -#include "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" -#include "Generated.xcconfig" diff --git a/example/ios/Flutter/Release.xcconfig b/example/ios/Flutter/Release.xcconfig deleted file mode 100644 index 399e934..0000000 --- a/example/ios/Flutter/Release.xcconfig +++ /dev/null @@ -1,2 +0,0 @@ -#include "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" -#include "Generated.xcconfig" diff --git a/example/ios/Podfile b/example/ios/Podfile deleted file mode 100644 index 1e8c3c9..0000000 --- a/example/ios/Podfile +++ /dev/null @@ -1,41 +0,0 @@ -# Uncomment this line to define a global platform for your project -# platform :ios, '9.0' - -# CocoaPods analytics sends network stats synchronously affecting flutter build latency. -ENV['COCOAPODS_DISABLE_STATS'] = 'true' - -project 'Runner', { - 'Debug' => :debug, - 'Profile' => :release, - 'Release' => :release, -} - -def flutter_root - generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) - unless File.exist?(generated_xcode_build_settings_path) - raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" - end - - File.foreach(generated_xcode_build_settings_path) do |line| - matches = line.match(/FLUTTER_ROOT\=(.*)/) - return matches[1].strip if matches - end - raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" -end - -require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) - -flutter_ios_podfile_setup - -target 'Runner' do - use_frameworks! - use_modular_headers! - - flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) -end - -post_install do |installer| - installer.pods_project.targets.each do |target| - flutter_additional_ios_build_settings(target) - end -end diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock deleted file mode 100644 index b69431a..0000000 --- a/example/ios/Podfile.lock +++ /dev/null @@ -1,22 +0,0 @@ -PODS: - - Flutter (1.0.0) - - integration_test (0.0.1): - - Flutter - -DEPENDENCIES: - - Flutter (from `Flutter`) - - integration_test (from `.symlinks/plugins/integration_test/ios`) - -EXTERNAL SOURCES: - Flutter: - :path: Flutter - integration_test: - :path: ".symlinks/plugins/integration_test/ios" - -SPEC CHECKSUMS: - Flutter: 434fef37c0980e73bb6479ef766c45957d4b510c - integration_test: 6eb66a19f7104200dcfdd62bc0077e1b09686e4f - -PODFILE CHECKSUM: aafe91acc616949ddb318b77800a7f51bffa2a4c - -COCOAPODS: 1.10.0 diff --git a/example/ios/Runner.xcodeproj/project.pbxproj b/example/ios/Runner.xcodeproj/project.pbxproj deleted file mode 100644 index 483ce5c..0000000 --- a/example/ios/Runner.xcodeproj/project.pbxproj +++ /dev/null @@ -1,539 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 46; - objects = { - -/* Begin PBXBuildFile section */ - 052090A8264F7CC7D59BAC4F /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 166593E2DDC1580301FBE4EB /* Pods_Runner.framework */; }; - 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; - 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; - 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; - 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; - 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; - 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; -/* End PBXBuildFile section */ - -/* Begin PBXCopyFilesBuildPhase section */ - 9705A1C41CF9048500538489 /* Embed Frameworks */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = ""; - dstSubfolderSpec = 10; - files = ( - ); - name = "Embed Frameworks"; - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXCopyFilesBuildPhase section */ - -/* Begin PBXFileReference section */ - 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; - 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; - 166593E2DDC1580301FBE4EB /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; - 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; - 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; - 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; - 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; - 979288937DB54B7A8E167BFD /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; - 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; - 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; - 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - ABECF997BE9D843ACE309A51 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; - BFE91A80E9CF01FB47297D69 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; -/* End PBXFileReference section */ - -/* Begin PBXFrameworksBuildPhase section */ - 97C146EB1CF9000F007C117D /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - 052090A8264F7CC7D59BAC4F /* Pods_Runner.framework in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - 421781B0B716E5E3F11A65B3 /* Pods */ = { - isa = PBXGroup; - children = ( - ABECF997BE9D843ACE309A51 /* Pods-Runner.debug.xcconfig */, - BFE91A80E9CF01FB47297D69 /* Pods-Runner.release.xcconfig */, - 979288937DB54B7A8E167BFD /* Pods-Runner.profile.xcconfig */, - ); - name = Pods; - path = Pods; - sourceTree = ""; - }; - 9740EEB11CF90186004384FC /* Flutter */ = { - isa = PBXGroup; - children = ( - 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, - 9740EEB21CF90195004384FC /* Debug.xcconfig */, - 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, - 9740EEB31CF90195004384FC /* Generated.xcconfig */, - ); - name = Flutter; - sourceTree = ""; - }; - 97C146E51CF9000F007C117D = { - isa = PBXGroup; - children = ( - 9740EEB11CF90186004384FC /* Flutter */, - 97C146F01CF9000F007C117D /* Runner */, - 97C146EF1CF9000F007C117D /* Products */, - 421781B0B716E5E3F11A65B3 /* Pods */, - C21EF53B5D1A17BF8839457D /* Frameworks */, - ); - sourceTree = ""; - }; - 97C146EF1CF9000F007C117D /* Products */ = { - isa = PBXGroup; - children = ( - 97C146EE1CF9000F007C117D /* Runner.app */, - ); - name = Products; - sourceTree = ""; - }; - 97C146F01CF9000F007C117D /* Runner */ = { - isa = PBXGroup; - children = ( - 97C146FA1CF9000F007C117D /* Main.storyboard */, - 97C146FD1CF9000F007C117D /* Assets.xcassets */, - 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, - 97C147021CF9000F007C117D /* Info.plist */, - 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, - 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, - 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, - 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, - ); - path = Runner; - sourceTree = ""; - }; - C21EF53B5D1A17BF8839457D /* Frameworks */ = { - isa = PBXGroup; - children = ( - 166593E2DDC1580301FBE4EB /* Pods_Runner.framework */, - ); - name = Frameworks; - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - 97C146ED1CF9000F007C117D /* Runner */ = { - isa = PBXNativeTarget; - buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; - buildPhases = ( - 0D390D42CD24A841DCDBE0FB /* [CP] Check Pods Manifest.lock */, - 9740EEB61CF901F6004384FC /* Run Script */, - 97C146EA1CF9000F007C117D /* Sources */, - 97C146EB1CF9000F007C117D /* Frameworks */, - 97C146EC1CF9000F007C117D /* Resources */, - 9705A1C41CF9048500538489 /* Embed Frameworks */, - 3B06AD1E1E4923F5004D2608 /* Thin Binary */, - 90F19155A8EB5CB4E9D778A3 /* [CP] Embed Pods Frameworks */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = Runner; - productName = Runner; - productReference = 97C146EE1CF9000F007C117D /* Runner.app */; - productType = "com.apple.product-type.application"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - 97C146E61CF9000F007C117D /* Project object */ = { - isa = PBXProject; - attributes = { - LastUpgradeCheck = 1020; - ORGANIZATIONNAME = ""; - TargetAttributes = { - 97C146ED1CF9000F007C117D = { - CreatedOnToolsVersion = 7.3.1; - LastSwiftMigration = 1100; - }; - }; - }; - buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; - compatibilityVersion = "Xcode 9.3"; - developmentRegion = en; - hasScannedForEncodings = 0; - knownRegions = ( - en, - Base, - ); - mainGroup = 97C146E51CF9000F007C117D; - productRefGroup = 97C146EF1CF9000F007C117D /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - 97C146ED1CF9000F007C117D /* Runner */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXResourcesBuildPhase section */ - 97C146EC1CF9000F007C117D /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, - 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, - 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, - 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - -/* Begin PBXShellScriptBuildPhase section */ - 0D390D42CD24A841DCDBE0FB /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; - }; - 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - ); - name = "Thin Binary"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; - }; - 90F19155A8EB5CB4E9D778A3 /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Embed Pods Frameworks"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; - 9740EEB61CF901F6004384FC /* Run Script */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - ); - name = "Run Script"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; - }; -/* End PBXShellScriptBuildPhase section */ - -/* Begin PBXSourcesBuildPhase section */ - 97C146EA1CF9000F007C117D /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, - 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin PBXVariantGroup section */ - 97C146FA1CF9000F007C117D /* Main.storyboard */ = { - isa = PBXVariantGroup; - children = ( - 97C146FB1CF9000F007C117D /* Base */, - ); - name = Main.storyboard; - sourceTree = ""; - }; - 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { - isa = PBXVariantGroup; - children = ( - 97C147001CF9000F007C117D /* Base */, - ); - name = LaunchScreen.storyboard; - sourceTree = ""; - }; -/* End PBXVariantGroup section */ - -/* Begin XCBuildConfiguration section */ - 249021D3217E4FDB00AE95B9 /* Profile */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 9.0; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = iphoneos; - SUPPORTED_PLATFORMS = iphoneos; - TARGETED_DEVICE_FAMILY = "1,2"; - VALIDATE_PRODUCT = YES; - }; - name = Profile; - }; - 249021D4217E4FDB00AE95B9 /* Profile */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - PRODUCT_BUNDLE_IDENTIFIER = com.example.example; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = Profile; - }; - 97C147031CF9000F007C117D /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = dwarf; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_DYNAMIC_NO_PIC = NO; - GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", - "$(inherited)", - ); - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 9.0; - MTL_ENABLE_DEBUG_INFO = YES; - ONLY_ACTIVE_ARCH = YES; - SDKROOT = iphoneos; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Debug; - }; - 97C147041CF9000F007C117D /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 9.0; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = iphoneos; - SUPPORTED_PLATFORMS = iphoneos; - SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; - TARGETED_DEVICE_FAMILY = "1,2"; - VALIDATE_PRODUCT = YES; - }; - name = Release; - }; - 97C147061CF9000F007C117D /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - PRODUCT_BUNDLE_IDENTIFIER = com.example.example; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = Debug; - }; - 97C147071CF9000F007C117D /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - PRODUCT_BUNDLE_IDENTIFIER = com.example.example; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 97C147031CF9000F007C117D /* Debug */, - 97C147041CF9000F007C117D /* Release */, - 249021D3217E4FDB00AE95B9 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 97C147061CF9000F007C117D /* Debug */, - 97C147071CF9000F007C117D /* Release */, - 249021D4217E4FDB00AE95B9 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; -/* End XCConfigurationList section */ - }; - rootObject = 97C146E61CF9000F007C117D /* Project object */; -} diff --git a/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 1d526a1..0000000 --- a/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist deleted file mode 100644 index 18d9810..0000000 --- a/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEDidComputeMac32BitWarning - - - diff --git a/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings deleted file mode 100644 index f9b0d7c..0000000 --- a/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings +++ /dev/null @@ -1,8 +0,0 @@ - - - - - PreviewsEnabled - - - diff --git a/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme deleted file mode 100644 index a28140c..0000000 --- a/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ /dev/null @@ -1,91 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/example/ios/Runner.xcworkspace/contents.xcworkspacedata b/example/ios/Runner.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 21a3cc1..0000000 --- a/example/ios/Runner.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - diff --git a/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist deleted file mode 100644 index 18d9810..0000000 --- a/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEDidComputeMac32BitWarning - - - diff --git a/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings deleted file mode 100644 index f9b0d7c..0000000 --- a/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings +++ /dev/null @@ -1,8 +0,0 @@ - - - - - PreviewsEnabled - - - diff --git a/example/ios/Runner/AppDelegate.swift b/example/ios/Runner/AppDelegate.swift deleted file mode 100644 index 70693e4..0000000 --- a/example/ios/Runner/AppDelegate.swift +++ /dev/null @@ -1,13 +0,0 @@ -import UIKit -import Flutter - -@UIApplicationMain -@objc class AppDelegate: FlutterAppDelegate { - override func application( - _ application: UIApplication, - didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? - ) -> Bool { - GeneratedPluginRegistrant.register(with: self) - return super.application(application, didFinishLaunchingWithOptions: launchOptions) - } -} diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index d36b1fa..0000000 --- a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,122 +0,0 @@ -{ - "images" : [ - { - "size" : "20x20", - "idiom" : "iphone", - "filename" : "Icon-App-20x20@2x.png", - "scale" : "2x" - }, - { - "size" : "20x20", - "idiom" : "iphone", - "filename" : "Icon-App-20x20@3x.png", - "scale" : "3x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@1x.png", - "scale" : "1x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@2x.png", - "scale" : "2x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@3x.png", - "scale" : "3x" - }, - { - "size" : "40x40", - "idiom" : "iphone", - "filename" : "Icon-App-40x40@2x.png", - "scale" : "2x" - }, - { - "size" : "40x40", - "idiom" : "iphone", - "filename" : "Icon-App-40x40@3x.png", - "scale" : "3x" - }, - { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "Icon-App-60x60@2x.png", - "scale" : "2x" - }, - { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "Icon-App-60x60@3x.png", - "scale" : "3x" - }, - { - "size" : "20x20", - "idiom" : "ipad", - "filename" : "Icon-App-20x20@1x.png", - "scale" : "1x" - }, - { - "size" : "20x20", - "idiom" : "ipad", - "filename" : "Icon-App-20x20@2x.png", - "scale" : "2x" - }, - { - "size" : "29x29", - "idiom" : "ipad", - "filename" : "Icon-App-29x29@1x.png", - "scale" : "1x" - }, - { - "size" : "29x29", - "idiom" : "ipad", - "filename" : "Icon-App-29x29@2x.png", - "scale" : "2x" - }, - { - "size" : "40x40", - "idiom" : "ipad", - "filename" : "Icon-App-40x40@1x.png", - "scale" : "1x" - }, - { - "size" : "40x40", - "idiom" : "ipad", - "filename" : "Icon-App-40x40@2x.png", - "scale" : "2x" - }, - { - "size" : "76x76", - "idiom" : "ipad", - "filename" : "Icon-App-76x76@1x.png", - "scale" : "1x" - }, - { - "size" : "76x76", - "idiom" : "ipad", - "filename" : "Icon-App-76x76@2x.png", - "scale" : "2x" - }, - { - "size" : "83.5x83.5", - "idiom" : "ipad", - "filename" : "Icon-App-83.5x83.5@2x.png", - "scale" : "2x" - }, - { - "size" : "1024x1024", - "idiom" : "ios-marketing", - "filename" : "Icon-App-1024x1024@1x.png", - "scale" : "1x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png deleted file mode 100644 index dc9ada4..0000000 Binary files a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png and /dev/null differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png deleted file mode 100644 index 28c6bf0..0000000 Binary files a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and /dev/null differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png deleted file mode 100644 index 2ccbfd9..0000000 Binary files a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and /dev/null differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png deleted file mode 100644 index f091b6b..0000000 Binary files a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and /dev/null differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png deleted file mode 100644 index 4cde121..0000000 Binary files a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and /dev/null differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png deleted file mode 100644 index d0ef06e..0000000 Binary files a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and /dev/null differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png deleted file mode 100644 index dcdc230..0000000 Binary files a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and /dev/null differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png deleted file mode 100644 index 2ccbfd9..0000000 Binary files a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and /dev/null differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png deleted file mode 100644 index c8f9ed8..0000000 Binary files a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and /dev/null differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png deleted file mode 100644 index a6d6b86..0000000 Binary files a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and /dev/null differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png deleted file mode 100644 index a6d6b86..0000000 Binary files a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and /dev/null differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png deleted file mode 100644 index 75b2d16..0000000 Binary files a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and /dev/null differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png deleted file mode 100644 index c4df70d..0000000 Binary files a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and /dev/null differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png deleted file mode 100644 index 6a84f41..0000000 Binary files a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and /dev/null differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png deleted file mode 100644 index d0e1f58..0000000 Binary files a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and /dev/null differ diff --git a/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json deleted file mode 100644 index 0bedcf2..0000000 --- a/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "images" : [ - { - "idiom" : "universal", - "filename" : "LaunchImage.png", - "scale" : "1x" - }, - { - "idiom" : "universal", - "filename" : "LaunchImage@2x.png", - "scale" : "2x" - }, - { - "idiom" : "universal", - "filename" : "LaunchImage@3x.png", - "scale" : "3x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} diff --git a/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png deleted file mode 100644 index 9da19ea..0000000 Binary files a/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png and /dev/null differ diff --git a/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png deleted file mode 100644 index 9da19ea..0000000 Binary files a/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png and /dev/null differ diff --git a/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png deleted file mode 100644 index 9da19ea..0000000 Binary files a/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png and /dev/null differ diff --git a/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md deleted file mode 100644 index 89c2725..0000000 --- a/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# Launch Screen Assets - -You can customize the launch screen with your own desired assets by replacing the image files in this directory. - -You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/example/ios/Runner/Base.lproj/LaunchScreen.storyboard b/example/ios/Runner/Base.lproj/LaunchScreen.storyboard deleted file mode 100644 index f2e259c..0000000 --- a/example/ios/Runner/Base.lproj/LaunchScreen.storyboard +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/example/ios/Runner/Base.lproj/Main.storyboard b/example/ios/Runner/Base.lproj/Main.storyboard deleted file mode 100644 index f3c2851..0000000 --- a/example/ios/Runner/Base.lproj/Main.storyboard +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/example/ios/Runner/Info.plist b/example/ios/Runner/Info.plist deleted file mode 100644 index a060db6..0000000 --- a/example/ios/Runner/Info.plist +++ /dev/null @@ -1,45 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - example - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleSignature - ???? - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - LSRequiresIPhoneOS - - UILaunchStoryboardName - LaunchScreen - UIMainStoryboardFile - Main - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UIViewControllerBasedStatusBarAppearance - - - diff --git a/example/ios/Runner/Runner-Bridging-Header.h b/example/ios/Runner/Runner-Bridging-Header.h deleted file mode 100644 index 308a2a5..0000000 --- a/example/ios/Runner/Runner-Bridging-Header.h +++ /dev/null @@ -1 +0,0 @@ -#import "GeneratedPluginRegistrant.h" diff --git a/example/lib/main.dart b/example/lib/main.dart index ceb048c..e65bc7c 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,106 +1,283 @@ -import 'package:async_button_builder/async_button_builder.dart'; import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:material_async_button/material_async_button.dart'; -void main() => runApp(MyApp()); +void main() => runApp(const MyApp()); -class MyApp extends StatelessWidget { +class const MyApp({super.key}) extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( - title: 'Flutter Demo', - theme: ThemeData(primarySwatch: Colors.blue), - home: MyHomePage(), + title: 'material_async_button demo', + theme: ThemeData(colorSchemeSeed: Colors.indigo), + home: const HomePage(), ); } } -class MyHomePage extends StatelessWidget { +class const HomePage({super.key}) extends StatefulWidget { @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: Text('Async Buttons')), - body: SizedBox.expand( - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Spacer(), - Divider(), - Text('Text Button:'), - AsyncButtonBuilder( - child: Text('Click Me'), - onPressed: () async { - await Future.delayed(Duration(seconds: 1)); - }, - builder: (context, child, callback) { - return TextButton( - child: child, - onPressed: callback, - ); - }, - ), - Divider(), - Text('Elevated Button:'), - AsyncButtonBuilder( - child: Text('Click Me'), - onPressed: () async { - await Future.delayed(Duration(seconds: 1)); - }, - builder: (context, child, callback) { - return ElevatedButton( - child: child, - onPressed: callback, - ); - }, + State createState() => _HomePageState(); +} + +class _HomePageState extends State { + final _formController = AsyncButtonController(); + final _externalController = AsyncButtonController(); + final _textController = TextEditingController(); + + @override + void dispose() { + _formController.dispose(); + _externalController.dispose(); + _textController.dispose(); + super.dispose(); + } + + Future _simulateWork({bool fail = false}) async { + await Future.delayed(const Duration(seconds: 1)); + if (fail) { + throw Exception('simulated failure'); + } + } + + /// A button is not the place to surface errors. A throw from `onPressed` + /// returns the button to idle and re-propagates — so handle it here (in real + /// apps, in your state management) and surface it your way. + Future _submit(BuildContext context, {bool fail = false}) async { + try { + await _simulateWork(fail: fail); + } on Exception catch (error) { + if (context.mounted) { + final snackBar = SnackBar(content: Text('Failed: $error')); + ScaffoldMessenger.of(context).showSnackBar(snackBar); + } + } + } + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: const Text('material_async_button')), + body: SingleChildScrollView( + padding: const .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: () => _submit(context, fail: true), + child: const Text('OutlinedAsyncButton (handles failure)'), + ), + const SizedBox(height: 8), + TextAsyncButton( + onPressed: _simulateWork, + child: const Text('TextAsyncButton'), + ), + const SizedBox(height: 8), + FilledAsyncButton( + onPressed: _simulateWork, + enabled: false, + child: const Text('Disabled (enabled: false)'), + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: .center, + children: [ + ElevatedAsyncButton( + onPressed: _simulateWork, + child: const Text('Intrinsic'), + ), + const SizedBox(width: 8), + ElevatedAsyncButton( + onPressed: _simulateWork, + maintainSize: true, + child: const Text('maintainSize'), + ), + ], + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: .spaceEvenly, + children: [ + IconAsyncButton( + onPressed: _simulateWork, + icon: const Icon(Icons.refresh), + ), + IconAsyncButton.filled( + onPressed: _simulateWork, + icon: const Icon(Icons.add), + ), + IconAsyncButton.filledTonal( + onPressed: _simulateWork, + icon: const Icon(Icons.edit), + ), + IconAsyncButton.outlined( + onPressed: _simulateWork, + icon: const Icon(Icons.delete), + ), + ], + ), + const Divider(height: 32), + const _SectionLabel('Floating Action Buttons'), + Row( + mainAxisAlignment: .spaceEvenly, + children: [ + FloatingActionAsyncButton( + onPressed: _simulateWork, + child: const Icon(Icons.navigation), + ), + FloatingActionAsyncButton.small( + onPressed: _simulateWork, + child: const Icon(Icons.navigation), + ), + FloatingActionAsyncButton.large( + onPressed: _simulateWork, + child: const Icon(Icons.navigation), + ), + FloatingActionAsyncButton.extended( + onPressed: _simulateWork, + icon: const Icon(Icons.navigation), + label: const Text('Navigate'), + ), + ], + ), + const Divider(height: 32), + const _SectionLabel('Animated spinner swap'), + FilledAsyncButton( + onPressed: _simulateWork, + transitionBuilder: _animatedSwitchSize, + child: const Text('Save (animated)'), + ), + const Divider(height: 32), + const _SectionLabel('Custom loading widget'), + ElevatedAsyncButton( + onPressed: _simulateWork, + loadingBuilder: (_) => const AsyncButtonSpinner(strokeWidth: 3), + child: const Text('Thicker spinner'), + ), + const Divider(height: 32), + const _SectionLabel('Form "Done" → controller.trigger()'), + TextField( + controller: _textController, + 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.reset, + child: const Text('reset()'), + ), + ], + ), + const Divider(height: 32), + const _SectionLabel('Custom button via AsyncButton'), + AsyncButton( + onPressed: _simulateWork, + loadingBuilder: (context) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: const AsyncButtonSpinner(strokeWidth: 3), ), - Divider(), - Text('Custom Outline Button:'), - AsyncButtonBuilder( - child: Text('Click Me'), - loadingWidget: Text('Loading...'), - onPressed: () async { - await Future.delayed(Duration(seconds: 1)); - }, - builder: (context, child, callback) { - return OutlineButton( - child: child, - onPressed: callback, - ); - }, + builder: (context, child, callback, isLoading) { + return Material( + color: isLoading ? Colors.indigo.shade200 : Colors.indigo, + clipBehavior: .hardEdge, + shape: const StadiumBorder(), + child: InkWell(onTap: callback, child: child), + ); + }, + child: const Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Text('Custom', style: TextStyle(color: Colors.white)), ), - Divider(), - Text('Custom Material Button:'), - const SizedBox(height: 6.0), - Material( - color: Colors.lightBlue, - shape: StadiumBorder(), - child: AsyncButtonBuilder( - valueColor: Colors.white, - padding: const EdgeInsets.symmetric( - horizontal: 16.0, - vertical: 8.0, - ), - loadingPadding: const EdgeInsets.all(8.0), - child: Text( - 'Click Me', - style: TextStyle(color: Colors.white), - ), - onPressed: () async { - await Future.delayed(Duration(seconds: 1)); - }, - builder: (context, child, callback) { - return InkWell( - child: child, - onTap: callback, - ); - }, - ), + ), + const Divider(height: 32), + const _SectionLabel('Custom button style OutlinedButton'), + OutlinedAsyncButton.icon( + onPressed: _simulateWork, + icon: const Icon(Icons.save), + label: const Text('Save'), + style: OutlinedButton.styleFrom( + padding: const .symmetric(vertical: 16, horizontal: 24), + textStyle: GoogleFonts.dmSans(fontSize: 15, fontWeight: .w500), ), - Divider(), - Spacer(), - ], - ), + ), + ], ), - ); - } + ), + ); +} + +/// Animates the spinner swap: [AnimatedSwitcher] cross-fades between the idle +/// child and the loading spinner (already keyed by loading state), and +/// [AnimatedSize] tweens the button's width as they differ in size. +Widget _animatedSwitchSize(BuildContext context, Widget child, bool isLoading) { + return AnimatedSize( + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 250), + child: child, + ), + ); +} + +class const _SectionLabel(final String text) extends StatelessWidget { + @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.lock b/example/pubspec.lock deleted file mode 100644 index a380070..0000000 --- a/example/pubspec.lock +++ /dev/null @@ -1,393 +0,0 @@ -# Generated by pub -# See https://dart.dev/tools/pub/glossary#lockfile -packages: - _fe_analyzer_shared: - dependency: transitive - description: - name: _fe_analyzer_shared - url: "https://pub.dartlang.org" - source: hosted - version: "12.0.0" - analyzer: - dependency: transitive - description: - name: analyzer - url: "https://pub.dartlang.org" - source: hosted - version: "0.40.6" - archive: - dependency: transitive - description: - name: archive - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.13" - args: - dependency: transitive - description: - name: args - url: "https://pub.dartlang.org" - source: hosted - version: "1.6.0" - async: - dependency: transitive - description: - name: async - url: "https://pub.dartlang.org" - source: hosted - version: "2.5.0-nullsafety.3" - async_button_builder: - dependency: "direct main" - description: - path: ".." - relative: true - source: path - version: "0.0.1-nullsafety.0" - boolean_selector: - dependency: transitive - description: - name: boolean_selector - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.0-nullsafety.3" - characters: - dependency: transitive - description: - name: characters - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.0-nullsafety.5" - charcode: - dependency: transitive - description: - name: charcode - url: "https://pub.dartlang.org" - source: hosted - version: "1.2.0-nullsafety.3" - cli_util: - dependency: transitive - description: - name: cli_util - url: "https://pub.dartlang.org" - source: hosted - version: "0.2.0" - clock: - dependency: transitive - description: - name: clock - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.0-nullsafety.3" - collection: - dependency: transitive - description: - name: collection - url: "https://pub.dartlang.org" - source: hosted - version: "1.15.0-nullsafety.5" - convert: - dependency: transitive - description: - name: convert - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.1" - coverage: - dependency: transitive - description: - name: coverage - url: "https://pub.dartlang.org" - source: hosted - version: "0.14.2" - crypto: - dependency: transitive - description: - name: crypto - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.5" - cupertino_icons: - dependency: "direct main" - description: - name: cupertino_icons - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.2" - fake_async: - dependency: transitive - description: - name: fake_async - url: "https://pub.dartlang.org" - source: hosted - version: "1.2.0-nullsafety.3" - file: - dependency: transitive - description: - name: file - url: "https://pub.dartlang.org" - source: hosted - version: "6.0.0-nullsafety.4" - flutter: - dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" - flutter_driver: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - flutter_test: - dependency: "direct dev" - description: flutter - source: sdk - version: "0.0.0" - fuchsia_remote_debug_protocol: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - glob: - dependency: transitive - description: - name: glob - url: "https://pub.dartlang.org" - source: hosted - version: "1.2.0" - integration_test: - dependency: "direct dev" - description: flutter - source: sdk - version: "0.9.2+2" - io: - dependency: transitive - description: - name: io - url: "https://pub.dartlang.org" - source: hosted - version: "0.3.4" - js: - dependency: transitive - description: - name: js - url: "https://pub.dartlang.org" - source: hosted - version: "0.6.3-nullsafety.3" - json_rpc_2: - dependency: transitive - description: - name: json_rpc_2 - url: "https://pub.dartlang.org" - source: hosted - version: "2.2.2" - logging: - dependency: transitive - description: - name: logging - url: "https://pub.dartlang.org" - source: hosted - version: "0.11.4" - matcher: - dependency: transitive - description: - name: matcher - url: "https://pub.dartlang.org" - source: hosted - version: "0.12.10-nullsafety.3" - meta: - dependency: transitive - description: - name: meta - url: "https://pub.dartlang.org" - source: hosted - version: "1.3.0-nullsafety.6" - node_interop: - dependency: transitive - description: - name: node_interop - url: "https://pub.dartlang.org" - source: hosted - version: "1.2.1" - node_io: - dependency: transitive - description: - name: node_io - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.1" - package_config: - dependency: transitive - description: - name: package_config - url: "https://pub.dartlang.org" - source: hosted - version: "1.9.3" - path: - dependency: transitive - description: - name: path - url: "https://pub.dartlang.org" - source: hosted - version: "1.8.0-nullsafety.3" - pedantic: - dependency: transitive - description: - name: pedantic - url: "https://pub.dartlang.org" - source: hosted - version: "1.10.0-nullsafety.3" - platform: - dependency: transitive - description: - name: platform - url: "https://pub.dartlang.org" - source: hosted - version: "3.0.0-nullsafety.4" - pool: - dependency: transitive - description: - name: pool - url: "https://pub.dartlang.org" - source: hosted - version: "1.5.0-nullsafety.3" - process: - dependency: transitive - description: - name: process - url: "https://pub.dartlang.org" - source: hosted - version: "4.0.0-nullsafety.4" - pub_semver: - dependency: transitive - description: - name: pub_semver - url: "https://pub.dartlang.org" - source: hosted - version: "1.4.4" - sky_engine: - dependency: transitive - description: flutter - source: sdk - version: "0.0.99" - source_map_stack_trace: - dependency: transitive - description: - name: source_map_stack_trace - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.0-nullsafety.4" - source_maps: - dependency: transitive - description: - name: source_maps - url: "https://pub.dartlang.org" - source: hosted - version: "0.10.10-nullsafety.3" - source_span: - dependency: transitive - description: - name: source_span - url: "https://pub.dartlang.org" - source: hosted - version: "1.8.0-nullsafety.4" - stack_trace: - dependency: transitive - description: - name: stack_trace - url: "https://pub.dartlang.org" - source: hosted - version: "1.10.0-nullsafety.6" - stream_channel: - dependency: transitive - description: - name: stream_channel - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.0-nullsafety.3" - string_scanner: - dependency: transitive - description: - name: string_scanner - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.0-nullsafety.3" - sync_http: - dependency: transitive - description: - name: sync_http - url: "https://pub.dartlang.org" - source: hosted - version: "0.2.0" - term_glyph: - dependency: transitive - description: - name: term_glyph - url: "https://pub.dartlang.org" - source: hosted - version: "1.2.0-nullsafety.3" - test_api: - dependency: transitive - description: - name: test_api - url: "https://pub.dartlang.org" - source: hosted - version: "0.2.19-nullsafety.6" - test_core: - dependency: transitive - description: - name: test_core - url: "https://pub.dartlang.org" - source: hosted - version: "0.3.12-nullsafety.9" - typed_data: - dependency: transitive - description: - name: typed_data - url: "https://pub.dartlang.org" - source: hosted - version: "1.3.0-nullsafety.5" - vector_math: - dependency: transitive - description: - name: vector_math - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.0-nullsafety.5" - vm_service: - dependency: transitive - description: - name: vm_service - url: "https://pub.dartlang.org" - source: hosted - version: "5.5.0" - watcher: - dependency: transitive - description: - name: watcher - url: "https://pub.dartlang.org" - source: hosted - version: "0.9.7+15" - web_socket_channel: - dependency: transitive - description: - name: web_socket_channel - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.0" - webdriver: - dependency: transitive - description: - name: webdriver - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.2" - yaml: - dependency: transitive - description: - name: yaml - url: "https://pub.dartlang.org" - source: hosted - version: "2.2.1" -sdks: - dart: ">=2.12.0-0.0 <3.0.0" - flutter: ">=1.17.0" diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 904d70b..f6d7e13 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -1,24 +1,24 @@ 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: ">=2.7.0 <3.0.0" + sdk: ^3.13.0 + flutter: ">=3.47.0" dependencies: flutter: sdk: flutter - cupertino_icons: ^1.0.1 - - async_button_builder: + google_fonts: ^9.0.0 + material_async_button: path: ../ dev_dependencies: + checks: ^0.3.1 + flutter_lints: ^6.0.0 flutter_test: sdk: flutter - integration_test: - sdk: flutter flutter: uses-material-design: true diff --git a/example/test/widget_test.dart b/example/test/widget_test.dart index 747db1d..c4843a0 100644 --- a/example/test/widget_test.dart +++ b/example/test/widget_test.dart @@ -1,30 +1,53 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility that Flutter provides. 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. - await tester.pumpWidget(MyApp()); + testWidgets('builds and shows the Material wrappers section', (tester) async { + await tester.pumpWidget(const MyApp()); + await tester.pumpAndSettle(); + + 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(); + + await tester.tap(find.text('ElevatedAsyncButton')); + await tester.pump(); + + check(find.byType(CircularProgressIndicator)).findsOne(); + + await tester.pumpAndSettle(); + check(find.byType(CircularProgressIndicator)).findsNone(); + }); - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); + testWidgets('failing action returns to idle and shows the error SnackBar', ( + 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('OutlinedAsyncButton (handles failure)')); await tester.pump(); + check(find.byType(CircularProgressIndicator)).findsOne(); - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); + await tester.pumpAndSettle(); + check(find.byType(CircularProgressIndicator)).findsNone(); + check(find.text('Failed: Exception: simulated failure')).findsOne(); }); } diff --git a/example/web/favicon.png b/example/web/favicon.png deleted file mode 100644 index 8aaa46a..0000000 Binary files a/example/web/favicon.png and /dev/null differ diff --git a/example/web/icons/Icon-192.png b/example/web/icons/Icon-192.png deleted file mode 100644 index b749bfe..0000000 Binary files a/example/web/icons/Icon-192.png and /dev/null differ diff --git a/example/web/icons/Icon-512.png b/example/web/icons/Icon-512.png deleted file mode 100644 index 88cfd48..0000000 Binary files a/example/web/icons/Icon-512.png and /dev/null differ diff --git a/example/web/index.html b/example/web/index.html deleted file mode 100644 index 1460b5e..0000000 --- a/example/web/index.html +++ /dev/null @@ -1,45 +0,0 @@ - - - - - - - - - - - - - - - - - - - - example - - - - - - - - diff --git a/example/web/manifest.json b/example/web/manifest.json deleted file mode 100644 index 8c01291..0000000 --- a/example/web/manifest.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "name": "example", - "short_name": "example", - "start_url": ".", - "display": "standalone", - "background_color": "#0175C2", - "theme_color": "#0175C2", - "description": "A new Flutter project.", - "orientation": "portrait-primary", - "prefer_related_applications": false, - "icons": [ - { - "src": "icons/Icon-192.png", - "sizes": "192x192", - "type": "image/png" - }, - { - "src": "icons/Icon-512.png", - "sizes": "512x512", - "type": "image/png" - } - ] -} diff --git a/lib/async_button_builder.dart b/lib/async_button_builder.dart deleted file mode 100644 index 3edf9c5..0000000 --- a/lib/async_button_builder.dart +++ /dev/null @@ -1,126 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; - -typedef _AsyncWidgetBuilder = Widget Function( - BuildContext context, - Widget child, - AsyncCallback? callback, -); - -class AsyncButtonBuilder extends StatefulWidget { - final _AsyncWidgetBuilder builder; - - final Widget child; - - final Duration duration; - - final AsyncCallback onPressed; - - final bool? _isLoading; - - final Widget? _loadingWidget; - - final Color? _valueColor; - - final EdgeInsets? _padding; - - final EdgeInsets? _loadingPadding; - - const AsyncButtonBuilder({ - Key? key, - required this.builder, - required this.child, - required this.onPressed, - this.duration = const Duration(milliseconds: 250), - bool? isLoading, - Widget? loadingWidget, - Color? valueColor, - EdgeInsets? padding, - EdgeInsets? loadingPadding, - }) : _loadingWidget = loadingWidget, - _isLoading = isLoading, - _valueColor = valueColor, - _padding = padding, - _loadingPadding = loadingPadding, - super(key: key); - - @override - _AsyncButtonBuilderState createState() => _AsyncButtonBuilderState(); -} - -class _AsyncButtonBuilderState extends State - with SingleTickerProviderStateMixin { - late bool isLoading; - - @override - void initState() { - isLoading = widget._isLoading ?? false; - super.initState(); - } - - @override - void didUpdateWidget(covariant AsyncButtonBuilder oldWidget) { - if (widget._isLoading != oldWidget._isLoading) { - final loading = widget._isLoading; - - if (loading != null) { - setState(() { - isLoading = loading; - }); - } - } - - super.didUpdateWidget(oldWidget); - } - - @override - Widget build(BuildContext context) { - final color = widget._valueColor; - final padding = widget._padding; - final loadingPadding = widget._loadingPadding; - final valueColor = - color == null ? null : AlwaysStoppedAnimation(color); - final child = padding == null - ? widget.child - : Padding(padding: padding, child: widget.child); - var loadingWidget = widget._loadingWidget ?? - SizedBox( - height: 16.0, - width: 16.0, - child: CircularProgressIndicator( - valueColor: valueColor, - ), - ); - loadingWidget = loadingPadding == null - ? loadingWidget - : Padding(padding: loadingPadding, child: loadingWidget); - - return widget.builder( - context, - AnimatedSize( - duration: widget.duration, - vsync: this, - child: isLoading ? loadingWidget : child, - ), - isLoading - ? null - : () async { - setState(() { - isLoading = true; - }); - - try { - await widget.onPressed(); - } catch (error) { - rethrow; - } finally { - if (mounted) { - setState(() { - isLoading = false; - }); - } - } - }, - ); - } -} diff --git a/lib/material_async_button.dart b/lib/material_async_button.dart new file mode 100644 index 0000000..f6559dd --- /dev/null +++ b/lib/material_async_button.dart @@ -0,0 +1,21 @@ +/// Drop-in async wrappers for Flutter Material buttons. +/// +/// See `ElevatedAsyncButton`, `FilledAsyncButton`, `OutlinedAsyncButton`, +/// `TextAsyncButton`, `IconAsyncButton`, and `FloatingActionAsyncButton` for +/// the named-constructor variants. Use `AsyncButton` when you need a custom +/// non-Material button. +library; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +part 'src/async_button.dart'; +part 'src/async_button_controller.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/floating_action_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..f3b24ca --- /dev/null +++ b/lib/src/async_button.dart @@ -0,0 +1,280 @@ +part of '../material_async_button.dart'; + +/// Signature for [AsyncButton.builder]. +/// +/// `callback` is `null` **only** when the button is explicitly disabled +/// (`enabled: false` or `onPressed == null`). Loading never disables the +/// button: while loading, `callback` stays non-null so the button keeps its +/// enabled look (the spinner is the state indicator). Taps that can't run are +/// silently swallowed by the controller, so they never double-submit. +// Formatter generations disagree on splitting a generalized typedef header +// (`=` break vs `= Widget Function(`); pin one form so local dart format, +// CI, and pana's package-config-free dart_style all leave it untouched. +// dart format off +typedef AsyncButtonWidgetBuilder = + Widget Function( + BuildContext context, + Widget child, + AsyncCallback? callback, + // Builder callbacks are positional by Flutter convention (cf. + // ValueWidgetBuilder); a named bool here would be un-idiomatic. + // ignore: avoid_positional_boolean_parameters + bool isLoading, + ); + +/// Signature for [AsyncButton.transitionBuilder]. +/// +/// Wraps the current state's [child] (already keyed by loading state) to +/// animate the idle ⇄ loading swap — e.g. an [AnimatedSwitcher] inside an +/// [AnimatedSize]. The button does no animation of its own; return [child] +/// unchanged for an instant swap. See the README for a worked example. +typedef AsyncButtonTransitionBuilder = + Widget Function( + BuildContext context, + Widget child, + // Builder callbacks are positional by Flutter convention (cf. + // ValueWidgetBuilder); a named bool here would be un-idiomatic. + // ignore: avoid_positional_boolean_parameters + bool isLoading, + ); +// dart format on + +/// Low-level async-loading shell for arbitrary buttons. +/// +/// Prefer the named Material wrappers ([ElevatedAsyncButton], +/// [FilledAsyncButton], [OutlinedAsyncButton], [TextAsyncButton], +/// [IconAsyncButton], [FloatingActionAsyncButton]). Reach for [AsyncButton] +/// directly only when you need to render a non-Material button. +/// +/// The builder receives whether the button is loading — switch the chrome on +/// it: +/// +/// ```dart +/// AsyncButton( +/// onPressed: doWork, +/// child: const Text('Go'), +/// builder: (context, child, callback, isLoading) => MyCustomButton( +/// onTap: callback, +/// color: isLoading ? Colors.grey : Colors.indigo, +/// child: child, +/// ), +/// ) +/// ``` +class const AsyncButton({ + /// Idle widget. Replaced by [loadingBuilder] while loading. + required final Widget child, + + /// Async callback. `null` makes the button appear disabled. + required final AsyncCallback? onPressed, + + /// Renders the button chrome. See [AsyncButtonWidgetBuilder]. + required final AsyncButtonWidgetBuilder builder, + + /// Whether the button is interactive. When `false` it renders the disabled + /// look, ignores taps, and no-ops an external [AsyncButtonController.trigger] + /// — same as `onPressed: null`, but the affirmative form that pairs with a + /// tear-off `onPressed`. Defaults to `true`. + final bool enabled = true, + + /// External controller. When null, the widget creates and owns its own. + final AsyncButtonController? controller, + + /// Builds the widget shown while loading, with the [AsyncButton]'s own + /// [BuildContext]. Falls back to [AsyncButtonTheme.loadingBuilder], then to + /// an [AsyncButtonSpinner]. The spinner inherits the button's foreground + /// colour automatically — to recolour it, return + /// `AsyncButtonSpinner(color: ...)`. + final WidgetBuilder? loadingBuilder, + + /// Per-widget override of [AsyncButtonTheme.transitionBuilder]. The button + /// performs no animation unless this (or the theme's) builder adds one. + final AsyncButtonTransitionBuilder? transitionBuilder, + + /// Per-widget override of [AsyncButtonTheme.maintainSize]. When `true` the + /// loading view overlays the invisible [child], so the button keeps its idle + /// footprint (the larger of the two). Defaults to `false`. + final bool? maintainSize, + + /// Per-widget override of [AsyncButtonTheme.minLoadingDuration]: the + /// shortest time a run shows the loading view. Defaults to [Duration.zero]. + final Duration? minLoadingDuration, + super.key, +}) extends StatefulWidget { + /// Creates an [AsyncButton]. See the class doc for usage. + this; + + @override + State createState() => _AsyncButtonState(); +} + +class _AsyncButtonState extends State { + late AsyncButtonController controller; + + // The run this button last rendered as loading. A controller swap adopts + // only this run: in the same rebuild an earlier sibling may already have + // moved another button's run into the controller this button is leaving. + Future? _shownRun; + + /// The widget's `onPressed` once [AsyncButton.enabled] is applied — `null` + /// (disabled) when `enabled: false` or `onPressed == null`. Both disable + /// paths collapse here, so the controller and the builder callback stay in + /// agreement. + AsyncCallback? get effectiveOnPressed => + widget.enabled ? widget.onPressed : null; + + /// Handed to the builder. `null` when disabled (see [effectiveOnPressed]); + /// otherwise [AsyncButtonController.trigger]. Loading never disables the + /// button — trigger just no-ops while busy, so taps that can't run are + /// swallowed and the button keeps its enabled look. + AsyncCallback? get callback => + effectiveOnPressed == null ? null : controller.trigger; + + // Per-rebuild refresh of the bound onPressed / enabled. + void _bind() { + controller._bind(this, onPressed: effectiveOnPressed); + } + + // Binding is last-binder-wins (see AsyncButtonController._bind). When this + // button takes the controller from another one, it checks once the frame + // settles that the displaced button is gone or drives another controller; + // otherwise two mounted buttons share it. Only the displacing side checks, + // so a double mount is reported once. + void _bindAndCheck() { + final displaced = controller._bind(this, onPressed: effectiveOnPressed); + assert(() { + if (displaced is _AsyncButtonState) { + WidgetsBinding.instance.addPostFrameCallback((_) { + assert( + !(mounted && + displaced.mounted && + identical(displaced.controller, controller)), + 'An AsyncButtonController can drive only one mounted AsyncButton.', + ); + }); + } + return true; + }(), 'schedules the debug-only double-mount check'); + } + + @override + void initState() { + super.initState(); + controller = (widget.controller ?? AsyncButtonController()) + ..addListener(listener); + _bindAndCheck(); + } + + @override + void didUpdateWidget(covariant AsyncButton oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.controller != oldWidget.controller) { + final previous = controller; + final inFlight = identical(previous._inFlight, _shownRun) + ? _shownRun + : null; + previous + ..removeListener(listener) + .._unbind(this); + // Dispose only a controller we created ourselves — never one the caller + // owns. + if (oldWidget.controller == null) { + previous.dispose(); + } + controller = (widget.controller ?? AsyncButtonController()) + ..addListener(listener); + if (inFlight != null && !controller.value) { + controller._adopt(inFlight); + } + _bindAndCheck(); + } else { + // onPressed (and enabled) can change on every parent rebuild — keep the + // controller's copy current. + _bind(); + } + } + + // Unbind on deactivate, not dispose, and only if still the owner: a + // replacement button may already have bound (a multi-child parent inflates + // it before deactivating the old child), and the old dispose runs later + // still. + @override + void activate() { + super.activate(); + _bindAndCheck(); + } + + @override + void deactivate() { + controller._unbind(this); + super.deactivate(); + } + + @override + void dispose() { + controller.removeListener(listener); + // Dispose only an internally-owned controller. + if (widget.controller == null) { + controller.dispose(); + } + super.dispose(); + } + + void listener() { + if (mounted) { + setState(() {}); + } + } + + @override + Widget build(BuildContext context) { + final theme = AsyncButtonTheme.of(context); + final loadingBuilder = + widget.loadingBuilder ?? theme.loadingBuilder ?? _defaultLoadingBuilder; + final transitionBuilder = + widget.transitionBuilder ?? theme.transitionBuilder; + controller._minLoadingDuration = + widget.minLoadingDuration ?? theme.minLoadingDuration ?? Duration.zero; + + final isLoading = controller.value; + _shownRun = isLoading ? controller._inFlight : null; + // Without maintainSize the idle child is replaced outright and the button + // may resize to fit the loading view; with it the loading view overlays + // the invisible child. + final keepFootprint = widget.maintainSize ?? theme.maintainSize ?? false; + var content = switch ((isLoading, keepFootprint)) { + (false, _) => widget.child, + (true, false) => loadingBuilder(context), + (true, true) => Stack( + alignment: .center, + children: [ + // maintainSemantics stays false: screen readers hear only the + // loading view. + Visibility( + visible: false, + maintainState: true, + maintainAnimation: true, + maintainSize: true, + child: widget.child, + ), + loadingBuilder(context), + ], + ), + }; + + // Keyed by loading state so a user-supplied transitionBuilder can animate + // the swap (e.g. via AnimatedSwitcher / AnimatedSize). With no builder it + // is an instant swap. + content = KeyedSubtree(key: ValueKey(isLoading), child: content); + if (transitionBuilder != null) { + content = transitionBuilder(context, content, isLoading); + } + + return widget.builder(context, content, callback, isLoading); + } +} + +/// The loading view used when neither the widget nor the theme supplies a +/// [AsyncButton.loadingBuilder]: the default [AsyncButtonSpinner]. +Widget _defaultLoadingBuilder(BuildContext context) { + return const AsyncButtonSpinner(); +} diff --git a/lib/src/async_button_controller.dart b/lib/src/async_button_controller.dart new file mode 100644 index 0000000..900fd6d --- /dev/null +++ b/lib/src/async_button_controller.dart @@ -0,0 +1,180 @@ +part of '../material_async_button.dart'; + +/// A read-only [ValueListenable] of an [AsyncButton]'s loading state (`true` +/// while `onPressed` is in flight), plus imperative [trigger] / [reset]. +/// +/// Pipe it into a `ValueListenableBuilder` for reactive UI outside the +/// button. The loading [value] is observe-only — drive the button with +/// [trigger] / [reset]; there is no public setter, so external code can't +/// desync the displayed state from the running future. +/// +/// Use it to: +/// - trigger the attached `onPressed` from outside the button +/// (e.g. a form keyboard "Done" action), +/// - reset to idle, +/// - read [value] / [canTrigger] to gate surrounding UI. +/// +/// Dispose like any [ChangeNotifier]. +class AsyncButtonController extends ChangeNotifier + implements ValueListenable { + /// Creates a controller in the idle (not-loading) state. + new(); + + bool _isLoading = false; + + /// Whether `onPressed` is currently in flight. Observe-only — mutated by + /// [trigger] / [reset], never from outside. + @override + bool get value => _isLoading; + + /// True when [trigger] would actually run the attached callback (not loading + /// and a callback is attached). Use it to gate surrounding UI. + bool get canTrigger => !_isLoading && _onPressed != null; + + // Widget-owned configuration. Refreshed whenever the bound [AsyncButton] + // updates. + AsyncCallback? _onPressed; + + bool _isDisposed = false; + + // Run token: a run settles the loading state only while it is still the + // latest — reset() and adoption bump it, so an abandoned run's completion + // is ignored. + int _run = 0; + Future? _inFlight; + Object? _owner; + Duration _minLoadingDuration = Duration.zero; + + // Last binder wins; returns the owner it displaced, if any. A multi-child + // parent inflates a replacement button before it deactivates the old one, + // so a double mount is only detectable once the frame settles — AsyncButton + // checks the displaced owner then (debug only). + Object? _bind(Object owner, {required AsyncCallback? onPressed}) { + final displaced = identical(_owner, owner) ? null : _owner; + _owner = owner; + _onPressed = onPressed; + return displaced; + } + + void _unbind(Object owner) { + if (identical(_owner, owner)) { + _owner = null; + _onPressed = null; + } + } + + /// Sets the `onPressed` that [trigger] runs, with no owning button. + /// + /// Exposed only so tests can drive a detached controller; [AsyncButton] + /// binds through a library-private hook (the last button to bind wins, and + /// a button unbinds only while it is still the owner). Not part of the + /// consumer-facing API. + @visibleForTesting + // A named binding hook, not a property setter. + // ignore: use_setters_to_change_properties + void attach({required AsyncCallback? onPressed}) { + _onPressed = onPressed; + } + + /// Run the attached `onPressed`. No-op if already loading or if no callback + /// is attached. + /// + /// If `onPressed` throws, the button returns to idle and the returned Future + /// completes with the error; a tap's error reaches the surrounding zone (else + /// `PlatformDispatcher.instance.onError`). A button is not the place to + /// surface errors, so handle them in your state management. + Future trigger() async { + assert( + ChangeNotifier.debugAssertNotDisposed(this), + 'trigger() after dispose()', + ); + final onPressed = _onPressed; + if (_isLoading || onPressed == null) { + return; + } + final run = ++_run; + _setLoading(true); + final pending = _withFloor(onPressed, _minLoadingDuration); + _inFlight = pending; + try { + await pending; + } finally { + // Settle whether onPressed completed or threw; a throw propagates + // through finally (trigger rethrows) so the error reaches the caller, or + // for a tap the surrounding zone / PlatformDispatcher.instance.onError. + _settle(run); + } + } + + // onPressed runs inside the try so a synchronous throw still awaits the + // floor and settles. + static Future _withFloor( + AsyncCallback onPressed, + Duration floor, + ) async { + final minimum = floor > Duration.zero ? Future.delayed(floor) : null; + try { + await onPressed(); + } finally { + if (minimum != null) { + await minimum; + } + } + } + + // Runs in didUpdateWidget, i.e. during build: notifying now would rebuild + // listeners outside the adopting button mid-build. The adopting button reads + // value in its own build; everyone else hears after the frame, unless the + // run settled or was reset first (those notify on their own). + void _adopt(Future pending) { + final run = ++_run; + _inFlight = pending; + _isLoading = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!_isDisposed && run == _run && _isLoading) { + notifyListeners(); + } + }); + pending.then( + (_) => _settle(run), + onError: (Object _) => _settle(run), + ); + } + + void _settle(int run) { + if (run == _run) { + _inFlight = null; + _setLoading(false); + } + } + + /// Force the button back to idle and abandon the in-flight run. + /// + /// The button re-arms at once — an escape hatch for a hung future. The + /// abandoned run's completion no longer touches the loading state, and a tap + /// after [reset] is a deliberate new run. + void reset() { + _run++; + _inFlight = null; + _setLoading(false); + } + + /// Notifying mutation point (only [_adopt] writes silently). Dedupes (like + /// the former [ValueNotifier]) so listeners fire only on a real change, and + /// never notifies after [dispose]. + void _setLoading(bool value) { + if (_isDisposed || _isLoading == value) { + return; + } + _isLoading = value; + notifyListeners(); + } + + @override + void dispose() { + _isDisposed = true; + _onPressed = null; + _owner = null; + super.dispose(); + } +} diff --git a/lib/src/buttons/async_material_button.dart b/lib/src/buttons/async_material_button.dart new file mode 100644 index 0000000..a6fa4bf --- /dev/null +++ b/lib/src/buttons/async_material_button.dart @@ -0,0 +1,210 @@ +part of '../../material_async_button.dart'; + +/// Abstract base for the Material wrapper widgets shipped with this package +/// ([ElevatedAsyncButton], [FilledAsyncButton], [OutlinedAsyncButton], +/// [TextAsyncButton], [IconAsyncButton], [FloatingActionAsyncButton]). +/// +/// Owns the shared async surface — [onPressed], [controller], and the +/// theme-override knobs. Direct subclasses ([IconAsyncButton], +/// [FloatingActionAsyncButton]) implement [build] and forward these fields to +/// an [AsyncButton]; [AsyncStandardMaterialButton] provides it. For custom +/// non-Material buttons reach for [AsyncButton] directly. +abstract class const AsyncMaterialButton({ + /// See [AsyncButton.child]. + required final Widget child, + + /// See [AsyncButton.onPressed]. + required final AsyncCallback? onPressed, + + /// See [AsyncButton.enabled]. + final bool enabled = true, + + /// See [AsyncButton.controller]. + final AsyncButtonController? controller, + + /// See [AsyncButton.loadingBuilder]. + final WidgetBuilder? loadingBuilder, + + /// See [AsyncButton.transitionBuilder]. + final AsyncButtonTransitionBuilder? transitionBuilder, + + /// See [AsyncButton.maintainSize]. + final bool? maintainSize, + + /// See [AsyncButton.minLoadingDuration]. + final Duration? minLoadingDuration, + super.key, +}) extends StatelessWidget { + /// Subclass-only constructor. Forwards every field to [AsyncButton]. See + /// [AsyncButton] for the semantics of each parameter. + this; + + /// Resolves the loading builder this button hands to its [AsyncButton]: the + /// per-widget [loadingBuilder] wins, then [AsyncButtonTheme.loadingBuilder], + /// then a default spinner sized for the button's shape (see [_SpinnerSize]). + /// + /// The shape-aware default exists because the bare [AsyncButtonSpinner] sizes + /// to the ambient label line box — right for a text label, but an + /// icon-bearing button's idle height can be driven by the icon (taller than + /// the line box), so it would shrink while loading unless the icon is taken + /// into account. + WidgetBuilder _resolveLoadingBuilder( + BuildContext context, + _SpinnerSize sizing, + ) { + return loadingBuilder ?? + AsyncButtonTheme.of(context).loadingBuilder ?? + (context) => _DefaultLoadingSpinner(sizing); + } + + bool _resolveMaintainSize(BuildContext context) => + maintainSize ?? AsyncButtonTheme.of(context).maintainSize ?? false; +} + +/// 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 via `_buildButton` / +/// `_buildIconButton`. +/// +/// [IconAsyncButton] does not extend this — it carries a different field +/// set ([IconButton]'s API). +/// +/// Sealed: the four concrete buttons are the whole hierarchy and the build +/// hooks are library-private. To build a custom async button, compose +/// [AsyncButton] directly. +sealed class const AsyncStandardMaterialButton({ + required super.child, + required super.onPressed, + super.enabled, + super.controller, + super.loadingBuilder, + super.transitionBuilder, + super.maintainSize, + super.minLoadingDuration, + + /// 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 = false, + + /// Forwarded to the underlying Material button. + final Clip clipBehavior = .none, + + /// Forwarded to the underlying Material button. + final WidgetStatesController? statesController, + final Widget? _icon, + final IconAlignment? _iconAlignment, + super.key, +}) extends AsyncMaterialButton { + /// Subclass-only constructor. Adds Material parameters common to + /// [ElevatedButton]/[FilledButton]/[OutlinedButton]/[TextButton]. + this; + + @override + Widget build(BuildContext context) { + final keepFootprint = _resolveMaintainSize(context); + return AsyncButton( + onPressed: onPressed, + enabled: enabled, + controller: controller, + // An `.icon` constructor that drops its icon while loading sizes the + // spinner to the idle row, max(iconSize, lineBox); with maintainSize + // the icon stays and the spinner overlays only the label's line box. + loadingBuilder: _resolveLoadingBuilder( + context, + _icon != null && !keepFootprint ? .max : .fontSize, + ), + transitionBuilder: transitionBuilder, + maintainSize: keepFootprint, + minLoadingDuration: minLoadingDuration, + builder: (context, animatedChild, callback, isLoading) { + final longPress = enabled && !isLoading ? onLongPress : null; + if (_icon != null) { + return _buildIconButton( + onPressed: callback, + onLongPress: longPress, + icon: isLoading && !keepFootprint ? null : _icon, + label: animatedChild, + ); + } + return _buildButton( + onPressed: callback, + onLongPress: longPress, + child: animatedChild, + ); + }, + child: child, + ); + } + + /// Builds the non-icon Material button widget. + Widget _buildButton({ + required VoidCallback? onPressed, + required VoidCallback? onLongPress, + required Widget child, + }); + + /// Builds the icon Material button widget. + Widget _buildIconButton({ + required VoidCallback? onPressed, + required VoidCallback? onLongPress, + required Widget? icon, + required Widget label, + }); +} + +/// How [_DefaultLoadingSpinner] derives its dimension from the ambient theme, +/// chosen per button shape so the loading view keeps the idle footprint. +enum _SpinnerSize { + /// Text-only buttons — match the label's line-box height (its idle extent, + /// taller than the raw `fontSize`). + fontSize, + + /// Icon-only buttons ([IconAsyncButton]) — match the icon size. + iconSize, + + /// Icon + label buttons (the `.icon` constructors) — match the taller of the + /// icon size and the label line box, i.e. the idle row height. + max, +} + +double? _largest(double? a, double? b) => a == null + ? b + : b == null + ? a + : (a > b ? a : b); + +/// The shape-aware default loading view. Reads the resolved [IconTheme] / +/// [DefaultTextStyle] set by the surrounding Material button (the same scope +/// [AsyncButtonSpinner] reads for its colour) and sizes the spinner so the +/// button holds its idle height while loading. +class const _DefaultLoadingSpinner(final _SpinnerSize sizing) + extends StatelessWidget { + @override + Widget build(BuildContext context) { + final iconSize = IconTheme.of(context).size; + final dimension = switch (sizing) { + .fontSize => _ambientTextLineBox(context), + .iconSize => iconSize, + .max => _largest(iconSize, _ambientTextLineBox(context)), + }; + // A null dimension (icon-only with an unset icon size) lets + // AsyncButtonSpinner fall back to the ambient line box. + return AsyncButtonSpinner(size: dimension); + } +} diff --git a/lib/src/buttons/elevated_async_button.dart b/lib/src/buttons/elevated_async_button.dart new file mode 100644 index 0000000..fc15d94 --- /dev/null +++ b/lib/src/buttons/elevated_async_button.dart @@ -0,0 +1,93 @@ +part of '../../material_async_button.dart'; + +/// Async-aware [ElevatedButton]. While `onPressed` is running the label is +/// swapped for the loading widget. +class ElevatedAsyncButton extends AsyncStandardMaterialButton { + /// Mirrors [ElevatedButton.new]. + const new({ + required super.onPressed, + required super.child, + super.onLongPress, + super.onHover, + super.onFocusChange, + super.style, + super.focusNode, + super.autofocus, + super.clipBehavior, + super.statesController, + super.enabled, + super.controller, + super.loadingBuilder, + super.transitionBuilder, + super.maintainSize, + super.minLoadingDuration, + super.key, + }); + + /// Mirrors [ElevatedButton.icon]. The loading widget replaces `label` and + /// drops `icon`, unless `maintainSize` is set — then `icon` stays. + const new icon({ + required super.onPressed, + required super.icon, + required Widget label, + super.onLongPress, + super.onHover, + super.onFocusChange, + super.style, + super.focusNode, + super.autofocus, + super.clipBehavior, + super.statesController, + super.enabled, + super.iconAlignment, + super.controller, + super.loadingBuilder, + super.transitionBuilder, + super.maintainSize, + super.minLoadingDuration, + super.key, + }) : super(child: label); + + @override + Widget _buildButton({ + required VoidCallback? onPressed, + required VoidCallback? onLongPress, + required Widget child, + }) { + return ElevatedButton( + onPressed: onPressed, + onLongPress: onLongPress, + onHover: onHover, + onFocusChange: onFocusChange, + style: style, + focusNode: focusNode, + autofocus: autofocus, + clipBehavior: clipBehavior, + statesController: statesController, + child: child, + ); + } + + @override + Widget _buildIconButton({ + required VoidCallback? onPressed, + required VoidCallback? onLongPress, + required Widget? icon, + required Widget label, + }) { + return ElevatedButton.icon( + onPressed: onPressed, + onLongPress: onLongPress, + onHover: onHover, + onFocusChange: onFocusChange, + style: style, + focusNode: focusNode, + autofocus: autofocus, + clipBehavior: clipBehavior, + statesController: statesController, + iconAlignment: _iconAlignment, + icon: icon, + label: label, + ); + } +} diff --git a/lib/src/buttons/filled_async_button.dart b/lib/src/buttons/filled_async_button.dart new file mode 100644 index 0000000..07185a4 --- /dev/null +++ b/lib/src/buttons/filled_async_button.dart @@ -0,0 +1,173 @@ +part of '../../material_async_button.dart'; + +enum _FilledButtonVariant { 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 new({ + required super.onPressed, + required super.child, + super.onLongPress, + super.onHover, + super.onFocusChange, + super.style, + super.focusNode, + super.autofocus, + super.clipBehavior, + super.statesController, + super.enabled, + super.controller, + super.loadingBuilder, + super.transitionBuilder, + super.maintainSize, + super.minLoadingDuration, + super.key, + }) : _variant = .primary; + + /// Mirrors [FilledButton.tonal]. + const new tonal({ + required super.child, + required super.onPressed, + super.onLongPress, + super.onHover, + super.onFocusChange, + super.style, + super.focusNode, + super.autofocus, + super.clipBehavior, + super.statesController, + super.enabled, + super.controller, + super.loadingBuilder, + super.transitionBuilder, + super.maintainSize, + super.minLoadingDuration, + super.key, + }) : _variant = .tonal; + + /// Mirrors [FilledButton.icon]. + const new icon({ + required super.onPressed, + required super.icon, + required Widget label, + super.onLongPress, + super.onHover, + super.onFocusChange, + super.style, + super.focusNode, + super.autofocus, + super.clipBehavior, + super.statesController, + super.enabled, + super.iconAlignment, + super.controller, + super.loadingBuilder, + super.transitionBuilder, + super.maintainSize, + super.minLoadingDuration, + super.key, + }) : _variant = .primary, + super(child: label); + + /// Mirrors [FilledButton.tonalIcon]. + const new tonalIcon({ + required super.onPressed, + required super.icon, + required Widget label, + super.onLongPress, + super.onHover, + super.onFocusChange, + super.style, + super.focusNode, + super.autofocus, + super.clipBehavior, + super.statesController, + super.enabled, + super.iconAlignment, + super.controller, + super.loadingBuilder, + super.transitionBuilder, + super.maintainSize, + super.minLoadingDuration, + super.key, + }) : _variant = .tonal, + super(child: label); + + final _FilledButtonVariant _variant; + + @override + Widget _buildButton({ + required VoidCallback? onPressed, + required VoidCallback? onLongPress, + required Widget child, + }) { + return switch (_variant) { + .primary => FilledButton( + onPressed: onPressed, + onLongPress: onLongPress, + onHover: onHover, + onFocusChange: onFocusChange, + style: style, + focusNode: focusNode, + autofocus: autofocus, + clipBehavior: clipBehavior, + statesController: statesController, + child: child, + ), + .tonal => FilledButton.tonal( + onPressed: onPressed, + onLongPress: onLongPress, + onHover: onHover, + onFocusChange: onFocusChange, + style: style, + focusNode: focusNode, + autofocus: autofocus, + clipBehavior: clipBehavior, + statesController: statesController, + child: child, + ), + }; + } + + @override + Widget _buildIconButton({ + required VoidCallback? onPressed, + required VoidCallback? onLongPress, + required Widget? icon, + required Widget label, + }) { + return switch (_variant) { + .primary => FilledButton.icon( + onPressed: onPressed, + onLongPress: onLongPress, + onHover: onHover, + onFocusChange: onFocusChange, + style: style, + focusNode: focusNode, + autofocus: autofocus, + clipBehavior: clipBehavior, + statesController: statesController, + iconAlignment: _iconAlignment, + icon: icon, + label: label, + ), + .tonal => FilledButton.tonalIcon( + onPressed: onPressed, + onLongPress: onLongPress, + onHover: onHover, + onFocusChange: onFocusChange, + style: style, + focusNode: focusNode, + autofocus: autofocus, + clipBehavior: clipBehavior, + statesController: statesController, + iconAlignment: _iconAlignment, + icon: icon, + label: label, + ), + }; + } +} diff --git a/lib/src/buttons/floating_action_async_button.dart b/lib/src/buttons/floating_action_async_button.dart new file mode 100644 index 0000000..142aa9a --- /dev/null +++ b/lib/src/buttons/floating_action_async_button.dart @@ -0,0 +1,371 @@ +part of '../../material_async_button.dart'; + +enum _FloatingActionButtonVariant { standard, small, large, extended } + +class const _DefaultHeroTag(); + +const Object _defaultHeroTag = _DefaultHeroTag(); + +/// Async-aware [FloatingActionButton]. While `onPressed` is running the child +/// (or label, for `.extended`) is swapped for the loading widget. +class FloatingActionAsyncButton extends AsyncMaterialButton { + /// Mirrors [FloatingActionButton.new]. + const new({ + required super.onPressed, + Widget? child, + super.enabled, + super.controller, + super.loadingBuilder, + super.transitionBuilder, + super.maintainSize, + super.minLoadingDuration, + this.tooltip, + this.foregroundColor, + this.backgroundColor, + this.focusColor, + this.hoverColor, + this.splashColor, + this.elevation, + this.focusElevation, + this.hoverElevation, + this.highlightElevation, + this.disabledElevation, + this.mouseCursor, + this.shape, + this.clipBehavior = .none, + this.focusNode, + this.autofocus = false, + this.materialTapTargetSize, + this.isExtended = false, + this.heroTag = _defaultHeroTag, + this.enableFeedback, + this.mini = false, + super.key, + }) : _variant = .standard, + _icon = null, + _extendedIconLabelSpacing = null, + _extendedPadding = null, + _extendedTextStyle = null, + super(child: child ?? const SizedBox.shrink()); + + /// Mirrors [FloatingActionButton.small]. + const new small({ + required super.onPressed, + Widget? child, + super.enabled, + super.controller, + super.loadingBuilder, + super.transitionBuilder, + super.maintainSize, + super.minLoadingDuration, + this.tooltip, + this.foregroundColor, + this.backgroundColor, + this.focusColor, + this.hoverColor, + this.splashColor, + this.elevation, + this.focusElevation, + this.hoverElevation, + this.highlightElevation, + this.disabledElevation, + this.mouseCursor, + this.shape, + this.clipBehavior = .none, + this.focusNode, + this.autofocus = false, + this.materialTapTargetSize, + this.heroTag = _defaultHeroTag, + this.enableFeedback, + super.key, + }) : _variant = .small, + mini = false, + isExtended = false, + _icon = null, + _extendedIconLabelSpacing = null, + _extendedPadding = null, + _extendedTextStyle = null, + super(child: child ?? const SizedBox.shrink()); + + /// Mirrors [FloatingActionButton.large]. + const new large({ + required super.onPressed, + Widget? child, + super.enabled, + super.controller, + super.loadingBuilder, + super.transitionBuilder, + super.maintainSize, + super.minLoadingDuration, + this.tooltip, + this.foregroundColor, + this.backgroundColor, + this.focusColor, + this.hoverColor, + this.splashColor, + this.elevation, + this.focusElevation, + this.hoverElevation, + this.highlightElevation, + this.disabledElevation, + this.mouseCursor, + this.shape, + this.clipBehavior = .none, + this.focusNode, + this.autofocus = false, + this.materialTapTargetSize, + this.heroTag = _defaultHeroTag, + this.enableFeedback, + super.key, + }) : _variant = .large, + mini = false, + isExtended = false, + _icon = null, + _extendedIconLabelSpacing = null, + _extendedPadding = null, + _extendedTextStyle = null, + super(child: child ?? const SizedBox.shrink()); + + /// Mirrors [FloatingActionButton.extended]. + const new extended({ + required super.onPressed, + required Widget label, + this._icon, + super.enabled, + super.controller, + super.loadingBuilder, + super.transitionBuilder, + super.maintainSize, + super.minLoadingDuration, + this.tooltip, + this.foregroundColor, + this.backgroundColor, + this.focusColor, + this.hoverColor, + this.splashColor, + this.elevation, + this.focusElevation, + this.hoverElevation, + this.highlightElevation, + this.disabledElevation, + this.mouseCursor, + this.shape, + this.clipBehavior = .none, + this.focusNode, + this.autofocus = false, + this.materialTapTargetSize, + this.isExtended = true, + this.heroTag = _defaultHeroTag, + this.enableFeedback, + this._extendedIconLabelSpacing, + this._extendedPadding, + this._extendedTextStyle, + super.key, + }) : _variant = .extended, + mini = false, + super(child: label); + + /// Forwarded to the underlying [FloatingActionButton]. + final String? tooltip; + + /// Forwarded to the underlying [FloatingActionButton]. + final Color? foregroundColor; + + /// Forwarded to the underlying [FloatingActionButton]. + final Color? backgroundColor; + + /// Forwarded to the underlying [FloatingActionButton]. + final Color? focusColor; + + /// Forwarded to the underlying [FloatingActionButton]. + final Color? hoverColor; + + /// Forwarded to the underlying [FloatingActionButton]. + final Color? splashColor; + + /// Forwarded to the underlying [FloatingActionButton]. + final double? elevation; + + /// Forwarded to the underlying [FloatingActionButton]. + final double? focusElevation; + + /// Forwarded to the underlying [FloatingActionButton]. + final double? hoverElevation; + + /// Forwarded to the underlying [FloatingActionButton]. + final double? highlightElevation; + + /// Forwarded to the underlying [FloatingActionButton]. + final double? disabledElevation; + + /// Forwarded to the underlying [FloatingActionButton]. + final MouseCursor? mouseCursor; + + /// Forwarded to the underlying [FloatingActionButton]. + final ShapeBorder? shape; + + /// Forwarded to the underlying [FloatingActionButton]. + final Clip clipBehavior; + + /// Forwarded to the underlying [FloatingActionButton]. + final FocusNode? focusNode; + + /// Forwarded to the underlying [FloatingActionButton]. + final bool autofocus; + + /// Forwarded to the underlying [FloatingActionButton]. + final MaterialTapTargetSize? materialTapTargetSize; + + /// Forwarded to the underlying [FloatingActionButton]. + final bool isExtended; + + /// Forwarded to the underlying [FloatingActionButton]. The default is a + /// package sentinel, not Flutter's default tag, so no hero flight runs + /// between a plain [FloatingActionButton] and an async one. + final Object? heroTag; + + /// Forwarded to the underlying [FloatingActionButton]. + final bool? enableFeedback; + + /// Forwarded to the underlying [FloatingActionButton]. + final bool mini; + + final Widget? _icon; + final double? _extendedIconLabelSpacing; + final EdgeInsetsGeometry? _extendedPadding; + final TextStyle? _extendedTextStyle; + + final _FloatingActionButtonVariant _variant; + + @override + Widget build(BuildContext context) { + // A collapsed extended FAB renders only its icon, so the loading view must + // replace the icon rather than the hidden label. + final collapsedIcon = _variant == .extended && !isExtended ? _icon : null; + final keepFootprint = _resolveMaintainSize(context); + return AsyncButton( + onPressed: onPressed, + enabled: enabled, + controller: controller, + loadingBuilder: _resolveLoadingBuilder( + context, + _variant != .extended || collapsedIcon != null + ? .iconSize + : (keepFootprint && _icon != null ? .fontSize : .max), + ), + transitionBuilder: transitionBuilder, + maintainSize: keepFootprint, + minLoadingDuration: minLoadingDuration, + builder: (context, animatedChild, callback, isLoading) => + switch (_variant) { + .standard => _buildStandard(callback, animatedChild), + .small || .large => _buildSmallOrLarge(callback, animatedChild), + .extended => _buildExtended( + callback, + animatedChild, + isLoading: isLoading, + collapsed: collapsedIcon != null, + keepFootprint: keepFootprint, + ), + }, + child: collapsedIcon ?? child, + ); + } + + Widget _buildStandard(VoidCallback? callback, Widget animatedChild) { + return FloatingActionButton( + onPressed: callback, + tooltip: tooltip, + foregroundColor: foregroundColor, + backgroundColor: backgroundColor, + focusColor: focusColor, + hoverColor: hoverColor, + splashColor: splashColor, + elevation: elevation, + focusElevation: focusElevation, + hoverElevation: hoverElevation, + highlightElevation: highlightElevation, + disabledElevation: disabledElevation, + mouseCursor: mouseCursor, + shape: shape, + clipBehavior: clipBehavior, + focusNode: focusNode, + autofocus: autofocus, + materialTapTargetSize: materialTapTargetSize, + isExtended: isExtended, + heroTag: heroTag, + enableFeedback: enableFeedback, + mini: mini, + child: animatedChild, + ); + } + + Widget _buildSmallOrLarge(VoidCallback? callback, Widget animatedChild) { + final constructor = switch (_variant) { + .small => FloatingActionButton.small, + _ => FloatingActionButton.large, + }; + return constructor( + onPressed: callback, + tooltip: tooltip, + foregroundColor: foregroundColor, + backgroundColor: backgroundColor, + focusColor: focusColor, + hoverColor: hoverColor, + splashColor: splashColor, + elevation: elevation, + focusElevation: focusElevation, + hoverElevation: hoverElevation, + highlightElevation: highlightElevation, + disabledElevation: disabledElevation, + mouseCursor: mouseCursor, + shape: shape, + clipBehavior: clipBehavior, + focusNode: focusNode, + autofocus: autofocus, + materialTapTargetSize: materialTapTargetSize, + heroTag: heroTag, + enableFeedback: enableFeedback, + child: animatedChild, + ); + } + + Widget _buildExtended( + VoidCallback? callback, + Widget animatedChild, { + required bool isLoading, + required bool collapsed, + required bool keepFootprint, + }) { + return FloatingActionButton.extended( + onPressed: callback, + label: collapsed ? child : animatedChild, + icon: collapsed + ? animatedChild + : (isLoading && !keepFootprint ? null : _icon), + tooltip: tooltip, + foregroundColor: foregroundColor, + backgroundColor: backgroundColor, + focusColor: focusColor, + hoverColor: hoverColor, + splashColor: splashColor, + elevation: elevation, + focusElevation: focusElevation, + hoverElevation: hoverElevation, + highlightElevation: highlightElevation, + disabledElevation: disabledElevation, + mouseCursor: mouseCursor, + shape: shape, + clipBehavior: clipBehavior, + focusNode: focusNode, + autofocus: autofocus, + materialTapTargetSize: materialTapTargetSize, + isExtended: isExtended, + heroTag: heroTag, + enableFeedback: enableFeedback, + extendedIconLabelSpacing: _extendedIconLabelSpacing, + extendedPadding: _extendedPadding, + extendedTextStyle: _extendedTextStyle, + ); + } +} diff --git a/lib/src/buttons/icon_async_button.dart b/lib/src/buttons/icon_async_button.dart new file mode 100644 index 0000000..c2456d5 --- /dev/null +++ b/lib/src/buttons/icon_async_button.dart @@ -0,0 +1,302 @@ +part of '../../material_async_button.dart'; + +enum _IconButtonVariant { 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 the `loadingBuilder` output while loading. +/// +/// Pass a [tooltip] to keep the button's label for screen readers: while +/// loading the [icon] is replaced by the spinner, which announces "Loading", +/// and the tooltip keeps the idle label. +class IconAsyncButton extends AsyncMaterialButton { + /// Mirrors [IconButton.new]. + const new({ + required super.onPressed, + required this.icon, + super.enabled, + super.controller, + super.loadingBuilder, + super.transitionBuilder, + super.maintainSize, + super.minLoadingDuration, + 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, + this.onHover, + this.onLongPress, + this.statesController, + super.key, + }) : _variant = .standard, + super(child: icon); + + /// Mirrors [IconButton.filled]. + const new filled({ + required super.onPressed, + required this.icon, + super.enabled, + super.controller, + super.loadingBuilder, + super.transitionBuilder, + super.maintainSize, + super.minLoadingDuration, + 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, + this.onHover, + this.onLongPress, + this.statesController, + super.key, + }) : _variant = .filled, + super(child: icon); + + /// Mirrors [IconButton.filledTonal]. + const new filledTonal({ + required super.onPressed, + required this.icon, + super.enabled, + super.controller, + super.loadingBuilder, + super.transitionBuilder, + super.maintainSize, + super.minLoadingDuration, + 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, + this.onHover, + this.onLongPress, + this.statesController, + super.key, + }) : _variant = .filledTonal, + super(child: icon); + + /// Mirrors [IconButton.outlined]. + const new outlined({ + required super.onPressed, + required this.icon, + super.enabled, + super.controller, + super.loadingBuilder, + super.transitionBuilder, + super.maintainSize, + super.minLoadingDuration, + 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, + this.onHover, + this.onLongPress, + this.statesController, + super.key, + }) : _variant = .outlined, + super(child: icon); + + /// Idle icon. Swapped with the `loadingBuilder` output while loading. + 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]. Doubles as the button's + /// accessible label — recommended: the spinner that replaces [icon] while + /// loading announces only "Loading", so the tooltip keeps the idle label. + 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; + + /// Forwarded to the underlying [IconButton]. + final ValueChanged? onHover; + + /// Forwarded to the underlying [IconButton]. Dropped while loading. Like + /// [IconButton], a disabled button (`onPressed: null` or `enabled: false`) + /// ignores it. + final VoidCallback? onLongPress; + + /// Forwarded to the underlying [IconButton]. + final WidgetStatesController? statesController; + + final _IconButtonVariant _variant; + + Widget _buildIconButton({ + required VoidCallback? onPressed, + required Widget icon, + required bool isLoading, + }) { + final builder = switch (_variant) { + .standard => IconButton.new, + .filled => IconButton.filled, + .filledTonal => IconButton.filledTonal, + .outlined => IconButton.outlined, + }; + return builder( + onPressed: onPressed, + icon: icon, + 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: isLoading ? null : selectedIcon, + onHover: onHover, + onLongPress: isLoading ? null : onLongPress, + statesController: statesController, + ); + } + + @override + Widget build(BuildContext context) { + final keepFootprint = _resolveMaintainSize(context); + return AsyncButton( + onPressed: onPressed, + enabled: enabled, + controller: controller, + loadingBuilder: _resolveLoadingBuilder(context, .iconSize), + transitionBuilder: transitionBuilder, + maintainSize: keepFootprint, + minLoadingDuration: minLoadingDuration, + builder: (context, child, callback, isLoading) { + return _buildIconButton( + onPressed: callback, + icon: child, + isLoading: isLoading, + ); + }, + 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..b597835 --- /dev/null +++ b/lib/src/buttons/outlined_async_button.dart @@ -0,0 +1,91 @@ +part of '../../material_async_button.dart'; + +/// Async-aware [OutlinedButton]. +class OutlinedAsyncButton extends AsyncStandardMaterialButton { + /// Mirrors [OutlinedButton.new]. + const new({ + required super.onPressed, + required super.child, + super.onLongPress, + super.onHover, + super.onFocusChange, + super.style, + super.focusNode, + super.autofocus, + super.clipBehavior, + super.statesController, + super.enabled, + super.controller, + super.loadingBuilder, + super.transitionBuilder, + super.maintainSize, + super.minLoadingDuration, + super.key, + }); + + /// Mirrors [OutlinedButton.icon]. + const new icon({ + required super.onPressed, + required super.icon, + required Widget label, + super.onLongPress, + super.onHover, + super.onFocusChange, + super.style, + super.focusNode, + super.autofocus, + super.clipBehavior, + super.statesController, + super.enabled, + super.iconAlignment, + super.controller, + super.loadingBuilder, + super.transitionBuilder, + super.maintainSize, + super.minLoadingDuration, + super.key, + }) : super(child: label); + + @override + Widget _buildButton({ + required VoidCallback? onPressed, + required VoidCallback? onLongPress, + required Widget child, + }) { + return OutlinedButton( + onPressed: onPressed, + onLongPress: onLongPress, + onHover: onHover, + onFocusChange: onFocusChange, + style: style, + focusNode: focusNode, + autofocus: autofocus, + clipBehavior: clipBehavior, + statesController: statesController, + child: child, + ); + } + + @override + Widget _buildIconButton({ + required VoidCallback? onPressed, + required VoidCallback? onLongPress, + required Widget? icon, + required Widget label, + }) { + return OutlinedButton.icon( + onPressed: onPressed, + onLongPress: onLongPress, + onHover: onHover, + onFocusChange: onFocusChange, + style: style, + focusNode: focusNode, + autofocus: autofocus, + clipBehavior: clipBehavior, + statesController: statesController, + iconAlignment: _iconAlignment, + icon: icon, + label: label, + ); + } +} diff --git a/lib/src/buttons/text_async_button.dart b/lib/src/buttons/text_async_button.dart new file mode 100644 index 0000000..3be67bb --- /dev/null +++ b/lib/src/buttons/text_async_button.dart @@ -0,0 +1,98 @@ +part of '../../material_async_button.dart'; + +/// Async-aware [TextButton]. +class TextAsyncButton extends AsyncStandardMaterialButton { + /// Mirrors [TextButton.new]. + const new({ + required super.onPressed, + required super.child, + super.onLongPress, + super.onHover, + super.onFocusChange, + super.style, + super.focusNode, + super.autofocus, + super.clipBehavior, + super.statesController, + super.enabled, + super.controller, + super.loadingBuilder, + super.transitionBuilder, + super.maintainSize, + super.minLoadingDuration, + this.isSemanticButton = true, + super.key, + }); + + /// Mirrors [TextButton.icon]. + const new icon({ + required super.onPressed, + required super.icon, + required Widget label, + super.onLongPress, + super.onHover, + super.onFocusChange, + super.style, + super.focusNode, + super.autofocus, + super.clipBehavior, + super.statesController, + super.enabled, + super.iconAlignment, + super.controller, + super.loadingBuilder, + super.transitionBuilder, + super.maintainSize, + super.minLoadingDuration, + super.key, + }) : isSemanticButton = true, + super(child: label); + + /// Forwarded to [TextButton.isSemanticButton]. Ignored by + /// [TextAsyncButton.icon] — [TextButton.icon] has none. + final bool? isSemanticButton; + + @override + Widget _buildButton({ + required VoidCallback? onPressed, + required VoidCallback? onLongPress, + required Widget child, + }) { + return TextButton( + onPressed: onPressed, + onLongPress: onLongPress, + onHover: onHover, + onFocusChange: onFocusChange, + style: style, + focusNode: focusNode, + autofocus: autofocus, + clipBehavior: clipBehavior, + statesController: statesController, + isSemanticButton: isSemanticButton, + child: child, + ); + } + + @override + Widget _buildIconButton({ + required VoidCallback? onPressed, + required VoidCallback? onLongPress, + required Widget? icon, + required Widget label, + }) { + return TextButton.icon( + onPressed: onPressed, + onLongPress: onLongPress, + onHover: onHover, + onFocusChange: onFocusChange, + style: style, + focusNode: focusNode, + autofocus: autofocus, + clipBehavior: clipBehavior, + statesController: statesController, + iconAlignment: _iconAlignment, + icon: icon, + label: label, + ); + } +} diff --git a/lib/src/material_async_button_theme.dart b/lib/src/material_async_button_theme.dart new file mode 100644 index 0000000..9ed6d21 --- /dev/null +++ b/lib/src/material_async_button_theme.dart @@ -0,0 +1,226 @@ +part of '../material_async_button.dart'; + +/// App-wide defaults for `material_async_button` widgets, attached as a +/// [ThemeExtension] on [ThemeData]. Complements [ButtonStyle] / `ButtonThemeData` +/// — it carries only async behaviour (the loading view, its transition, its +/// footprint and minimum duration), never styling knobs. +/// +/// Resolution order for any field: per-widget value, then theme value, then +/// the hard-coded fallback. With no extension registered the zero-config +/// fallback ([empty]) shows the default spinner and nothing else. +/// +/// ```dart +/// MaterialApp( +/// theme: ThemeData( +/// extensions: [ +/// AsyncButtonTheme( +/// loadingBuilder: (_) => const AsyncButtonSpinner(strokeWidth: 3), +/// ), +/// ], +/// ), +/// ) +/// ``` +@immutable +class const AsyncButtonTheme({ + /// Builds the widget shown in place of the button's child while the future is + /// in flight. Falls back to an [AsyncButtonSpinner] when null. The spinner + /// inherits the button's foreground; return `AsyncButtonSpinner(color: ...)` + /// to recolour it. + final WidgetBuilder? loadingBuilder, + + /// Wraps the state widget to animate the idle ⇄ loading swap. `null` by + /// default — the button performs no animation of its own. See + /// [AsyncButtonTransitionBuilder] for plugging in an [AnimatedSwitcher] / + /// [AnimatedSize]. + final AsyncButtonTransitionBuilder? transitionBuilder, + + /// Whether the loading view overlays the invisible idle child instead of + /// replacing it, so the button keeps its idle footprint (no width jump). + /// Defaults to `false`. + final bool? maintainSize, + + /// The shortest time a run shows the loading view, measured from the tap or + /// [AsyncButtonController.trigger] — an anti-flicker floor for fast futures. + /// An error rethrows once the floor has elapsed. Defaults to [Duration.zero]. + final Duration? minLoadingDuration, +}) extends ThemeExtension { + /// Builds an [AsyncButtonTheme]. Every field is nullable so callers only + /// set the ones they want. + this; + + /// An extension with every field left null — also the zero-config fallback + /// returned by [of] when no extension is registered. Renders the default + /// spinner on loading and nothing else. + static const AsyncButtonTheme empty = AsyncButtonTheme(); + + /// Resolves the [AsyncButtonTheme] visible at [context]. Returns the + /// extension registered on the surrounding [ThemeData] when one exists; + /// otherwise falls back to [empty] — apps without explicit theming still get + /// the loading spinner (via the built-in fallback). + static AsyncButtonTheme of(BuildContext context) { + return Theme.of(context).extension() ?? empty; + } + + @override + AsyncButtonTheme copyWith({ + WidgetBuilder? loadingBuilder, + AsyncButtonTransitionBuilder? transitionBuilder, + bool? maintainSize, + Duration? minLoadingDuration, + }) { + return AsyncButtonTheme( + loadingBuilder: loadingBuilder ?? this.loadingBuilder, + transitionBuilder: transitionBuilder ?? this.transitionBuilder, + maintainSize: maintainSize ?? this.maintainSize, + minLoadingDuration: minLoadingDuration ?? this.minLoadingDuration, + ); + } + + @override + AsyncButtonTheme lerp(covariant AsyncButtonTheme? other, double t) { + if (other == null) { + return this; + } + // Widgets, callbacks and behaviour flags don't lerp; snap at the halfway + // point. + final snap = t < 0.5; + return AsyncButtonTheme( + loadingBuilder: snap ? loadingBuilder : other.loadingBuilder, + transitionBuilder: snap ? transitionBuilder : other.transitionBuilder, + maintainSize: snap ? maintainSize : other.maintainSize, + minLoadingDuration: snap ? minLoadingDuration : other.minLoadingDuration, + ); + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + other is AsyncButtonTheme && + loadingBuilder == other.loadingBuilder && + transitionBuilder == other.transitionBuilder && + maintainSize == other.maintainSize && + minLoadingDuration == other.minLoadingDuration; + } + + @override + int get hashCode { + return Object.hash( + loadingBuilder, + transitionBuilder, + maintainSize, + minLoadingDuration, + ); + } +} + +// Bounded cache to prevent thrashing between multiple button styles on screen, +// while avoiding unbounded memory growth in apps with dynamic text styles. +typedef _LineBoxKey = (TextStyle, TextDirection, TextScaler); +final _lineBoxCache = <_LineBoxKey, double>{}; + +// A font that loads after a style was measured (google_fonts, FontLoader) +// changes its line box; the cache is cleared on every system font change. +var _lineBoxCacheListensToFonts = false; + +/// The bounded line-box cache. Exposed only so tests can assert its capacity +/// and LRU eviction. Not part of the consumer-facing API. +@visibleForTesting +Map<(TextStyle, TextDirection, TextScaler), double> get debugLineBoxCache => + _lineBoxCache; + +/// The single-line height of the ambient label style at [context] — the +/// vertical extent a one-line [Text] occupies here. The default spinner sizes +/// to this (the idle content's *line box*, which is taller than the raw +/// `fontSize`) so the button keeps its idle height while loading instead of +/// shrinking. Honours the ambient [TextScaler], matching how the label scales. +double _ambientTextLineBox(BuildContext context) { + if (!_lineBoxCacheListensToFonts) { + _lineBoxCacheListensToFonts = true; + PaintingBinding.instance.systemFonts.addListener(_lineBoxCache.clear); + } + final style = DefaultTextStyle.of(context).style; + final textDirection = Directionality.of(context); + final textScaler = MediaQuery.textScalerOf(context); + final key = (style, textDirection, textScaler); + + final cached = _lineBoxCache.remove(key); + if (cached != null) { + _lineBoxCache[key] = cached; + return cached; + } + + if (_lineBoxCache.length >= 16) { + _lineBoxCache.remove(_lineBoxCache.keys.first); + } + + final painter = TextPainter( + text: TextSpan(text: '', style: style), + textDirection: textDirection, + textScaler: textScaler, + )..layout(); + final lineBox = painter.preferredLineHeight; + painter.dispose(); + + return _lineBoxCache[key] = lineBox; +} + +/// The default loading indicator — a sized, indeterminate +/// [CircularProgressIndicator]. Used as the fallback loading view, and exposed +/// so you can tweak it and return it from a `loadingBuilder`: +/// +/// ```dart +/// FilledAsyncButton( +/// onPressed: api.save, +/// loadingBuilder: (_) => const AsyncButtonSpinner(strokeWidth: 3), +/// child: const Text('Save'), +/// ) +/// ``` +class const AsyncButtonSpinner({ + /// Indicator colour. When null, inherits the button's foreground (its + /// enabled foreground while loading), falling back to [ColorScheme.primary] + /// outside any button. + final Color? color, + + /// Stroke width of the [CircularProgressIndicator]. + final double strokeWidth = 2, + + /// Side length of the square the indicator is laid out in. When null it + /// tracks the ambient label's line-box height (the vertical extent of a + /// one-line [Text] in the surrounding [DefaultTextStyle]), so the spinner + /// fills the same height the label did and the button doesn't shrink. + /// [IconThemeData.size] is intentionally not read here — it is typically 24 + /// and would oversize the spinner on text buttons. + final double? size, + + /// Accessibility label read by screen readers. Defaults to `'Loading'`. + final String? semanticsLabel = 'Loading', + super.key, +}) extends StatelessWidget { + /// Creates a spinner. [color] defaults to the button's foreground and + /// otherwise the primary colour; [strokeWidth] sets the line weight; [size] + /// sets the square the indicator occupies — when null it tracks the ambient + /// label line box so the spinner matches the button's label; [semanticsLabel] + /// sets the accessibility label read by screen readers. + this; + + @override + Widget build(BuildContext context) { + final resolved = + color ?? + IconTheme.of(context).color ?? + Theme.of(context).colorScheme.primary; + final dimension = size ?? _ambientTextLineBox(context); + return Center( + widthFactor: 1, + heightFactor: 1, + child: SizedBox.square( + dimension: dimension, + child: CircularProgressIndicator( + strokeWidth: strokeWidth, + valueColor: AlwaysStoppedAnimation(resolved), + semanticsLabel: semanticsLabel, + ), + ), + ); + } +} diff --git a/plans/sweep-fixes.md b/plans/sweep-fixes.md new file mode 100644 index 0000000..8ab7313 --- /dev/null +++ b/plans/sweep-fixes.md @@ -0,0 +1,550 @@ +--- +status: in-progress +created: 2026-10-03 +updated: 2026-10-04 +--- + +# Plan: material_async_button 3.1.0 — sweep fixes, parity params, maintainSize + minLoadingDuration + +Frontmatter `status` is the plan's lifecycle, one owner per transition: `draft` → `approved` (planner, on user approval, before commit) → `in-progress` (executor, before Phase 1) → deleted (executor's last commit, once every § Verification step ran and passed — one that couldn't run keeps it `in-progress`, logged to the § Found tracker). Plans are ephemeral: git is the archive. `outdated` — whoever finds § Files no longer matching the tree; an outdated plan is re-grounded or replaced, never executed. Every transition bumps `updated`. + +## Progress + +- [x] Phase 1: Loading button keeps its size (P0) +- [x] Phase 2: Fire-and-forget lint convention +- [x] Phase 3: Controller state machine +- [x] Phase 4: Variant fixes +- [x] Phase 5: Line-box measurement +- [x] Phase 6: Parity parameters +- [x] Phase 7: maintainSize + minLoadingDuration +- [x] Phase 8: Repo meta, packaging, CI +- [x] Phase 9: Docs, skill, example +- [ ] Phase 10: Release prep, push, CI + +## Problem + +Since 2.0.0 every non-FAB wrapper grows to fill its parent while loading; the controller double-runs or runs dead callbacks after `reset()`, unmount, swap or sharing; some variants lose the spinner; `.icon` constructors reject the documented `enabled:`; docs, skill and repo meta have drifted. 3.1.0 fixes all of it behind tests that failed first, and adds `maintainSize`, `minLoadingDuration` and the missing Material parameters. + +## Invariants + +- One library of `part` files (`lib/material_async_button.dart`): state↔controller wiring uses new library-private members, never new public API. Minor release, additive only: `AsyncButtonController.attach` and `debugLineBoxCache` stay public `@visibleForTesting`; no rename, removal or class-modifier change. +- Keep each file's Dart 3.13 constructor style (`class const X({...})`, `const new name({...})`); new parameters join those lists. Code below is shape, not layout — `dart format` reflows it. +- Loading never disables: the builder `callback` stays non-null while loading; only `enabled: false` / `onPressed: null` disable. Every `AsyncButtonTheme` field resolves widget → theme → built-in default. +- Maintainer rules: no `!`, no `unawaited(...)` (bare drop), no `late final`, comments only for traps/contracts; format tracked files only (`git ls-files -z -- '*.dart' | xargs -0 dart format`). Tests use `package:checks` + `test/_helpers.dart`; disposal errors are asserted `isA()` (beta throws `StateError` from `package:listen`, stable `FlutterError`). +- `analysis_options.yaml` is guarded by a PreToolUse hook (`~/.claude/hooks/config-protection.sh`). The edit this plan makes to it is a user-approved, deliberate config change (fleet decision X-17). In that step only: `touch .dart_tool/.config-edit-ok`, write the file, then `rm .dart_tool/.config-edit-ok` right away. Never use the marker for any other edit, and never leave it behind. +- `git-committer` commits **and pushes** by default. Every dispatch of it in this plan must say **"commit only, do not push"**, except the one push the final phase names. A phase commit must never reach the remote before that. + +## Decisions + +- Floor stays `sdk: ^3.13.0` / `flutter: ">=3.47.0"`, forward-only; release 3.1.0, floor first under `### Changed`. Commit directly on `main` (planner default) via the `git-committer` agent after each passing oracle (conventional commits); never publish, tag, create releases or change repo settings. +- `reset()` abandons the in-flight run: idle and re-armed at once (escape hatch for a hung future); the abandoned run's completion no longer touches state; a tap after `reset()` is a deliberate new run (planner default). +- One controller drives one mounted `AsyncButton`: a second binder fails a debug `assert`; release keeps last-binder-wins. Unbinding is owner-checked and happens in `deactivate()` (rebind in `activate()`). Swapping controllers mid-flight: the incoming controller adopts the in-flight future unless it is already loading. `trigger()` after `dispose()`: debug `assert`; release no-op (`dispose()` clears the callback). +- `maintainSize: true` overlays the loading view on the invisible idle child (footprint = max(idle, loading)); `.icon` constructors and non-collapsed `.extended` keep their icon visible under it (planner default). Default `false` keeps today's swap. +- `minLoadingDuration` holds loading at least that long from trigger start (tap or `controller.trigger()`); errors rethrow after the floor; default `Duration.zero` creates no timer. New theme fields snap in `lerp` at `t < 0.5`; `copyWith` cannot clear a field (documented, not changed). Default FAB `heroTag` sentinel: documented, not changed (planner default). +- `onPressed: null` + `onLongPress` gives an enabled button, as in Flutter (behaviour change, under `### Changed`; planner default). `isSemanticButton` only on `TextAsyncButton.new` (`TextButton.icon` has none); `IconAsyncButton.onLongPress` is gated only on loading (`IconButton` drops it when `onPressed == null`). +- Line-box cache: keep the bounded LRU; clear it from a `PaintingBinding.instance.systemFonts` listener registered once. CHANGELOG history stays as written (incl. the 2.0.0 `FlutterError.onError` claim) except the title and `### Breaking` capitalisation (planner default). The doc-snippet compile check is a scratch file, never committed (planner default). `bug_report.yaml` drops collection_notifiers' "What happened?" placeholder (planner default). + +## Files + +|Path|Action|Exact symbols| +|--|--|--| +|`lib/src/material_async_button_theme.dart`|modify|`AsyncButtonSpinner.build`; `_ambientTextLineBox`; new `_lineBoxCacheListensToFonts`; `AsyncButtonTheme` +`maintainSize`, +`minLoadingDuration`, `copyWith`, `lerp`, `==`, `hashCode`| +|`lib/src/async_button_controller.dart`|modify|new `_run`, `_inFlight`, `_owner`, `_minLoadingDuration`, `_bind`, `_unbind`, `_adopt`, `_settle`, `_withFloor`; `trigger`, `reset`, `dispose`| +|`lib/src/async_button.dart`|modify|`AsyncButton` +`maintainSize`, +`minLoadingDuration`; `_AsyncButtonState` `_bind`, `initState`, `didUpdateWidget`, `activate`, `deactivate`, `build`| +|`lib/src/buttons/async_material_button.dart`|modify|`AsyncMaterialButton` +2 params, `_resolveMaintainSize`; `AsyncStandardMaterialButton` +2 super params, `build`; `_loadingSizing` removed| +|`lib/src/buttons/{elevated,filled,outlined,text,icon,floating_action}_async_button.dart`|modify|all 18 constructors +2 params; `enabled` on the 5 `.icon`/`.tonalIcon`; `TextAsyncButton.isSemanticButton`; `IconAsyncButton` +`onHover`/`onLongPress`/`statesController`, `_buildIconButton`; FAB `build`, `_buildExtended`, `heroTag` doc| +|`test/{layout,icon_enabled,line_box_font}_test.dart`, `test/leak/{flutter_test_config,line_box_leak_test}.dart`|create|ported repros + feature oracles| +|`test/_helpers.dart`, `test/{async_button,async_button_controller,elevated_async_button,outlined_async_button,text_async_button,icon_async_button,floating_action_async_button,material_async_button_theme}_test.dart`|modify|ported repros, masking-test fixes, feature tests; `_helpers.dart`: delete `spinnerTextLineBox`, `asyncButtonTheme` +2 params, new `columnHost`| +|`pubspec.yaml`, `analysis_options.yaml`, `CHANGELOG.md`, `README.md`, `.github/workflows/ci.yaml`, `example/lib/main.dart`, `example/README.md`, `/Users/mehmetesen/pub-dev/SWEEP.md`|modify|see phases| +|`.pubignore`; `plans/sweep-fixes.md` (last commit)|delete|—| +|`.github/{dependabot.yml,PULL_REQUEST_TEMPLATE.md}`, `.github/ISSUE_TEMPLATE/{bug_report.yaml,feature_request.yaml,config.yml}`, `CONTRIBUTING.md`|create|fleet meta| +|`.github/workflows/publish.yaml`|create (restore)|deleted tag-publish workflow, tag trigger commented out| +|`skills/flutter-material-async-button/SKILL.md` → `skills/material-async-button-usage/SKILL.md`|`git mv` + modify|frontmatter, H1, drift| + +Repros (copy allowed): `/Users/mehmetesen/pub-dev/sweep-2026-10-03/repros/material_async_button/`. "Theme file" = `lib/src/material_async_button_theme.dart`; "wrapper files" = `lib/src/buttons/*_async_button.dart`. Each phase adds its bullets under `## Unreleased` in `CHANGELOG.md` (sections `### Added`, `### Changed`, `### Fixed`). + +## Phases + +### Phase 1: Loading button keeps its size (P0) + +- **Files**: theme file, `test/_helpers.dart`, `test/layout_test.dart`, `CHANGELOG.md`. +- **Change**: + 1. Failing first (MAB-M1): `test/layout_test.dart` from repro `layout_test.dart`, widened to all 18 constructors (Elevated `.new/.icon`; Filled `.new/.tonal/.icon/.tonalIcon`; Outlined `.new/.icon`; Text `.new/.icon`; Icon `.new/.filled/.filledTonal/.outlined`; FAB `.new/.small/.large/.extended`), label `Text('Save')`, icon `Icon(Icons.save)`, finder `find.bySubtype()` / `find.byType(IconButton)` / `find.byType(FloatingActionButton)`. Hosts: `pumpHost` (Center) and new `Widget columnHost(Widget child, {ThemeData? theme}) => MaterialApp(theme: theme ?? emptyAsyncButtonTheme, home: Scaffold(body: Column(children: [child])))`. Per case: `pendingPress()`, `idle = tester.getSize(finder)`, `await tester.tap(finder); await tester.pump();`, `loading = getSize`, complete, pump; `check(loading.height).equals(idle.height)`, `check(loading.width).isLessOrEqual(idle.width)`. Keep the repro's body-level `Row` IconAsyncButton height case. + 2. MAB-B1: `AsyncButtonSpinner.build` → `Center(widthFactor: 1, heightFactor: 1, child: SizedBox.square(...))`. CHANGELOG (MAB-X-8, MAB-I11): title `# Changelog`; `### BREAKING` → `### Breaking`; add `## Unreleased` with `### Changed` "Requires Dart 3.13 / Flutter 3.47 (was Dart 3.10 / Flutter 3.38)." and `### Fixed` "Loading buttons no longer grow to fill their parent (every non-FAB wrapper since 2.0.0, and custom `loadingBuilder`s returning `AsyncButtonSpinner`)." +- **Traps**: measure the Material button, never the spinner's `SizedBox` (the blind spot that hid B1). Load by tap — a dropped `trigger()` Future fails the lints until Phase 2. FAB cases pass before the fix (controls). +- **Oracle**: `flutter test test/layout_test.dart` + +### Phase 2: Fire-and-forget lint convention + +- **Files**: `analysis_options.yaml`, the 4 test files holding the 11 `unawaited(` hits. +- **Change** (MAB-X-17): `analysis_options.yaml` becomes exactly: + ```yaml + include: package:very_good_analysis/analysis_options.yaml + + analyzer: + exclude: + - build/** + + linter: + rules: + # Maintainer convention: fire-and-forget is a bare drop; never unawaited(). + unawaited_futures: false + discarded_futures: false + ``` + Replace every `unawaited()` with `;`; drop a `dart:async` import only where the analyzer reports it unused (`Completer` still needs it). +- **Oracle**: `flutter analyze --fatal-infos --fatal-warnings && ! rg -n 'unawaited\(' lib test example && flutter test` + +### Phase 3: Controller state machine + +- **Files**: `async_button_controller.dart`, `async_button.dart`, `test/async_button_controller_test.dart`, `test/async_button_test.dart`, `CHANGELOG.md`. +- **Change**: + 1. Failing first, from repro `controller_test.dart`. In `async_button_test.dart`: MAB-B2 tap → `reset()` → tap → complete run 1 → `calls == 2` and `c.value` true → complete run 2 → idle (do not port the repro's `calls == 1`; it contradicts the run-token decision). MAB-S1 internal controller, tap (pending), re-pump the same button with `controller: ext` → `ext.value` true; tap → `calls == 1`; complete → idle. MAB-B3 two `TextAsyncButton(controller: c)`, each in `SizedBox(width: 100, height: 48)`, in `Row(mainAxisSize: .min)` → `check(tester.takeException()).isA()`. MAB-B4 the repro's unmount and swap tests as written, plus controls that must pass: same-slot type swap (`ElevatedAsyncButton` → `TextAsyncButton`, same `c`), same-slot key swap, `GlobalKey` reparent into a `Padding` — each `takeException()` null, `c.canTrigger` true, `trigger()` runs the new callback. + In `async_button_controller_test.dart`, MAB-B10: attach, dispose, `check(c.canTrigger).isFalse()`, `await c.trigger().then((_) {}, onError: (Object e) => err = e)`; `ran == 0`, `err` `isA()`. + MAB-M2: in "swapping from external to internal controller" (`async_button_test.dart:314`) and "swapping the external controller transfers listening without leak" (`:342`) count `onPressed` calls per controller; the detached controller's count stays 0 after its `trigger()`. + 2. Controller: new members and rewritten methods (keep public docs; reword `reset()` per Decisions): + ```dart + int _run = 0; Future? _inFlight; Object? _owner; Duration _minLoadingDuration = Duration.zero; + void _bind(Object owner, {required AsyncCallback? onPressed}) { + assert(_owner == null || identical(_owner, owner), 'An AsyncButtonController can drive only one mounted AsyncButton.'); + _owner = owner; _onPressed = onPressed; + } + void _unbind(Object owner) { if (identical(_owner, owner)) { _owner = null; _onPressed = null; } } + Future trigger() async { + assert(ChangeNotifier.debugAssertNotDisposed(this), 'trigger() after dispose()'); + final onPressed = _onPressed; + if (_isLoading || onPressed == null) { return; } + final run = ++_run; _setLoading(true); + final pending = _withFloor(onPressed, _minLoadingDuration); _inFlight = pending; + try { await pending; } finally { _settle(run); } + } + static Future _withFloor(AsyncCallback onPressed, Duration floor) async { + final minimum = floor > Duration.zero ? Future.delayed(floor) : null; + try { await onPressed(); } finally { if (minimum != null) { await minimum; } } + } + void _adopt(Future pending) { + final run = ++_run; _inFlight = pending; _setLoading(true); + pending.then((_) => _settle(run), onError: (Object _) => _settle(run)); + } + void _settle(int run) { if (run == _run) { _inFlight = null; _setLoading(false); } } + void reset() { _run++; _inFlight = null; _setLoading(false); } + // dispose(): _isDisposed = true; _onPressed = null; _owner = null; super.dispose(); + ``` + `_minLoadingDuration` stays zero until Phase 7. + 3. `_AsyncButtonState`: `void _bind() => controller._bind(this, onPressed: effectiveOnPressed);`. `initState`: assign controller, `addListener(listener)`, `_bind()`. `didUpdateWidget` on controller change: `final previous = controller; final inFlight = previous._inFlight;` `previous..removeListener(listener).._unbind(this)`; dispose `previous` only when `oldWidget.controller == null`; assign + `addListener`; `if (inFlight != null && !controller.value) { controller._adopt(inFlight); }`; then always `_bind()`. Add `activate()` (`super.activate(); _bind();`) and `deactivate()` (`controller._unbind(this); super.deactivate();`). `dispose()` unchanged. CHANGELOG: `### Fixed` — reset mid-flight no longer lets the stale run clear a new run; a controller swap mid-flight keeps the loading state; an external controller detaches when its button unmounts or is swapped out (`canTrigger` false, `trigger()` no-op); `trigger()` after `dispose()` never runs `onPressed`. `### Changed` — one controller bound to two mounted buttons fails a debug assertion; `reset()` abandons the in-flight run. +- **Traps**: unbinding in `dispose` would trip the assert on every same-slot swap — the new element's `initState` runs before the old `dispose`, but after the old `deactivate`. An unbounded B3 host turns the `ErrorWidget` into a second (layout) exception and `takeException()` returns a String. `onPressed()` must run inside `_withFloor`'s `try` so a synchronous throw still settles. +- **Oracle**: `flutter test test/async_button_controller_test.dart test/async_button_test.dart` + +### Phase 4: Variant fixes + +- **Files**: the 6 wrapper files, `async_material_button.dart`, `test/icon_enabled_test.dart`, `test/{icon,floating_action,elevated}_async_button_test.dart`, `CHANGELOG.md`. +- **Change**: + 1. Failing first: `test/icon_enabled_test.dart` — the 5 `.icon`/`.tonalIcon` constructors with `enabled: false, onPressed: () async {}`, each pumped alone, `check(tester.widget(find.bySubtype()).enabled).isFalse()`; run it alone first (compile error expected). Repro `variants_test.dart` B5 → `icon_async_button_test.dart`, B6 → `floating_action_async_button_test.dart`; repro `hypotheses_test.dart` "onPressed: null + onLongPress keeps the button enabled" → `elevated_async_button_test.dart`. + 2. MAB-B7: `super.enabled,` after `super.statesController,` in `ElevatedAsyncButton.icon`, `FilledAsyncButton.icon`, `FilledAsyncButton.tonalIcon`, `OutlinedAsyncButton.icon`, `TextAsyncButton.icon`. + 3. MAB-B5: `IconAsyncButton._buildIconButton({required VoidCallback? onPressed, required Widget icon, required bool isLoading})` passes `selectedIcon: isLoading ? null : selectedIcon`. + 4. MAB-B6: `FloatingActionAsyncButton.build`: `final collapsedIcon = _variant == .extended && !isExtended ? _icon : null;` → `AsyncButton(child: collapsedIcon ?? child, …)`, sizing `_variant == .extended && collapsedIcon == null ? .max : .iconSize`; `_buildExtended(callback, animatedChild, isLoading:, collapsed: collapsedIcon != null)` with `icon: collapsed ? animatedChild : (isLoading ? null : _icon)`, `label: collapsed ? child : animatedChild`. + 5. MAB-I6: `AsyncStandardMaterialButton.build` → `final longPress = enabled && !isLoading ? onLongPress : null;`. CHANGELOG: `### Fixed` selected `IconAsyncButton` shows the spinner; collapsed extended FAB shows the spinner; `enabled` on every `.icon`/`.tonalIcon`. `### Changed` `onPressed: null` with `onLongPress` keeps the button enabled for long-press, as in Flutter. +- **Traps**: find `.icon` buttons with `find.bySubtype()` — they may be private subclasses. A collapsed extended FAB with no `icon` stays blank, as in Flutter. +- **Oracle**: `flutter test test/icon_enabled_test.dart test/icon_async_button_test.dart test/floating_action_async_button_test.dart test/elevated_async_button_test.dart` + +### Phase 5: Line-box measurement + +- **Files**: theme file, `pubspec.yaml`, `test/leak/*`, `test/line_box_font_test.dart`, `test/_helpers.dart`, `test/{elevated,outlined,text,floating_action}_async_button_test.dart`, `test/material_async_button_theme_test.dart`, `CHANGELOG.md`. +- **Change**: + 1. Failing first: dev dep `leak_tracker_flutter_testing: ^3.0.10` (resolves on 3.47.0 and 3.47.5); repro `leak/flutter_test_config.dart` → `test/leak/flutter_test_config.dart`, `leak/leak_test.dart` → `test/leak/line_box_leak_test.dart` (drop both `// ignore: depend_on_referenced_packages`). Repro `font_cache_test.dart` → `test/line_box_font_test.dart`, replacing the hard-coded SDK path fallback with `final root = Platform.environment['FLUTTER_ROOT']; if (root == null) fail('flutter test sets FLUTTER_ROOT');`. + 2. MAB-B8: `_ambientTextLineBox` reads `painter.preferredLineHeight` into a local, calls `painter.dispose()`, then caches and returns it. + 3. MAB-B9: top-level `var _lineBoxCacheListensToFonts = false;`; first thing in `_ambientTextLineBox`: if false, set true and `PaintingBinding.instance.systemFonts.addListener(_lineBoxCache.clear);`. + 4. MAB-M3: delete `spinnerTextLineBox`; at `elevated_async_button_test.dart:160`, `outlined_async_button_test.dart:82`, `text_async_button_test.dart:48`, `floating_action_async_button_test.dart:214` use `tester.getSize(find.text('