From b6d0911f10310f96ee86c2f93d8c1c646972b7b4 Mon Sep 17 00:00:00 2001 From: Ilya Nixan Date: Wed, 19 Aug 2026 19:54:15 +0300 Subject: [PATCH] chore: update to version 2.0.0 with breaking changes, added Flutter Web support, and improved cancellation handling --- .github/workflows/publish.yaml | 14 ++++- CHANGELOG.md | 50 ++++++++++------- README.md | 53 +++++++++++++++++- lib/src/cupertino_progress_bar_dialog.dart | 6 +- lib/src/result.dart | 24 ++++++-- lib/src/show_progress_dialog.dart | 5 +- pubspec.yaml | 2 +- test/progress_dialog_test.dart | 64 +++++++++++++++------- 8 files changed, 165 insertions(+), 53 deletions(-) diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index f7b908a..2c3b673 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -5,9 +5,10 @@ on: jobs: - test: - name: Run tests + verify: + name: Analyze and test runs-on: ubuntu-latest + timeout-minutes: 15 steps: - uses: actions/checkout@v3 @@ -19,12 +20,19 @@ jobs: - name: Install dependencies run: flutter pub get + - name: Analyze project source + run: dart analyze --fatal-infos + - name: Run tests run: flutter test + # Guards web support: a `dart:io` import in lib/ fails to compile here. + - name: Run tests on web + run: flutter test --platform chrome + publish: name: Publish to pub.dev - needs: [ test ] + needs: [ verify ] permissions: id-token: write # Required for authentication using OIDC uses: dart-lang/setup-dart/.github/workflows/publish.yml@v1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 201a77f..ee93185 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,28 +1,40 @@ -## Unreleased +## 2.0.0 -**Breaking:** `ProgressDialogResult` is sealed and now has a third variant, so -exhaustive `switch` statements over it need a `Cancelled` case added. +### Breaking changes -* Fixed "Null check operator used on a null value" crash when the dialog was - dismissed before the task completed — by the Android back button, or by a - custom `builder` calling `Navigator.pop` -* Added `Cancelled` result variant, `isCancelled`, and - `ProgressDialogResult.cancelled()` -* `unwrap()` throws `ProgressDialogCancelledException` for a cancelled result; - `map()` and `flatMap()` pass cancellation through -* Note: the task is not interrupted on cancellation — Dart futures cannot be - cancelled, so it runs to completion and its result is discarded -* Fixed the returned future never completing when the hosting `Navigator` is - disposed while the task is in flight — for example a nested navigator removed - from the widget tree. The call now settles with `Cancelled` instead of leaving - the caller awaiting forever -* Added Flutter Web support — `flutter_future_progress_dialog` no longer imports - `dart:io`, so pub.dev now reports all six platforms -* **Behaviour change:** `showAdaptiveProgressDialog` now selects its style from +* `ProgressDialogResult` gained a third variant, `Cancelled`. The type is + sealed, so exhaustive `switch` statements over a result need a `Cancelled` + case added. See "Migrating from 1.x" in the README +* `showAdaptiveProgressDialog` now selects its style from `Theme.of(context).platform` instead of the host operating system, matching Flutter's own `showAdaptiveDialog`. An app that sets `ThemeData(platform: ...)` now gets the style it asked for rather than the style of the machine it runs on +### Added + +* Flutter Web support — `flutter_future_progress_dialog` no longer imports + `dart:io`, so pub.dev now reports all six platforms +* `Cancelled` result variant, the `isCancelled` getter, and + `ProgressDialogResult.cancelled()` +* `ProgressDialogCancelledException`, thrown by `unwrap()` on a cancelled result + +### Fixed + +* "Null check operator used on a null value" crash when the dialog was dismissed + before the task completed — by the Android back button, or by a custom + `builder` calling `Navigator.pop` +* The returned future never completing when the hosting `Navigator` was disposed + while the task was in flight, which left the caller awaiting forever. The call + now settles with `Cancelled` +* `map()` and `flatMap()` now pass cancellation through instead of failing to + compile against the widened result type + +### Notes + +* Cancellation dismisses the dialog, not the task. Dart futures cannot be + cancelled, so the work runs to completion in the background and its result is + discarded + ## 1.5.0 * Fixed crash when dialog context unmounts before task completes diff --git a/README.md b/README.md index a2cdd7a..ea72e47 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ Developed and maintained by [Nerdy Pro](https://nerdy.pro). - [Usage](#usage) - [API reference](#api-reference) - [FAQ](#faq) +- [Migrating from 1.x](#migrating-from-1x) - [Known limitations](#known-limitations) - [License](#license) @@ -132,7 +133,7 @@ Or add it to `pubspec.yaml` manually: ```yaml dependencies: - flutter_future_progress_dialog: ^1.5.0 + flutter_future_progress_dialog: ^2.0.0 ``` Then import it: @@ -318,6 +319,56 @@ Yes. `flutter_future_progress_dialog` supports all six Flutter platforms — And Yes, if you use the `BuildContext` afterwards. `showProgressDialog` closes its own dialog, but your `context` may still have been unmounted while the task was running, so guard any subsequent use of it as you would after any `await`. +## Migrating from 1.x + +Version 2.0.0 has two breaking changes. + +### `ProgressDialogResult` has a third variant + +`Cancelled` was added so that dismissing the dialog reports an outcome instead +of crashing. `ProgressDialogResult` is sealed, so any exhaustive `switch` over a +result now fails to compile with a message like: + +``` +The type 'ProgressDialogResult' isn't exhaustively matched by the switch +cases since it doesn't match the pattern 'Cancelled()'. +``` + +Add the missing arm: + +```dart +switch (result) { + case Success(:final value): + // ... + case Failure(:final error): + // ... + case Cancelled(): // <-- add this + // The dialog was dismissed before the task delivered a result +} +``` + +If you would rather not handle cancellation distinctly, treat it like a failure: + +```dart +if (result.isCancelled) return; +``` + +Code using `isSuccess`, `isError`, `unwrap()`, `map()`, or `flatMap()` keeps +compiling unchanged. Be aware that `unwrap()` now throws +`ProgressDialogCancelledException` for a cancelled result, and that a `Success` +holding `null` is still a `Success` — it is never reported as `Cancelled`. + +### `showAdaptiveProgressDialog` follows the theme, not the host OS + +The style is now chosen from `Theme.of(context).platform`, matching Flutter's own +`showAdaptiveDialog`. Previously it read the host operating system through +`dart:io`. + +This only changes behaviour for apps that override `ThemeData.platform` — those +now get the dialog style they asked for. It is also what makes the package work +on Flutter Web, where the old check threw. To pin one style regardless of +platform, call `showProgressDialog` or `showCupertinoProgressDialog` directly. + ## Known limitations - **The task cannot be aborted.** Cancellation dismisses the dialog, not the work. See [Can the user cancel the progress dialog?](#can-the-user-cancel-the-progress-dialog) diff --git a/lib/src/cupertino_progress_bar_dialog.dart b/lib/src/cupertino_progress_bar_dialog.dart index 239b353..ae38665 100644 --- a/lib/src/cupertino_progress_bar_dialog.dart +++ b/lib/src/cupertino_progress_bar_dialog.dart @@ -49,7 +49,8 @@ class _CupertinoProgressBarDialog extends State { child: LayoutBuilder( builder: (context, constraints) { return AnimatedPadding( - padding: MediaQuery.viewInsetsOf(context) + const EdgeInsets.symmetric(horizontal: 40.0, vertical: 24.0), + padding: MediaQuery.viewInsetsOf(context) + + const EdgeInsets.symmetric(horizontal: 40.0, vertical: 24.0), duration: widget.insetAnimationDuration, curve: widget.insetAnimationCurve, child: MediaQuery.removeViewInsets( @@ -60,7 +61,8 @@ class _CupertinoProgressBarDialog extends State { context: context, child: Center( child: Container( - margin: const EdgeInsets.symmetric(vertical: _kDialogEdgePadding), + margin: const EdgeInsets.symmetric( + vertical: _kDialogEdgePadding), width: _kCupertinoProgressSize, height: _kCupertinoProgressSize, child: const CupertinoPopupSurface( diff --git a/lib/src/result.dart b/lib/src/result.dart index e57823a..06673d5 100644 --- a/lib/src/result.dart +++ b/lib/src/result.dart @@ -34,7 +34,9 @@ sealed class ProgressDialogResult { static ProgressDialogResult success(T value) => Success(value); /// Creates a failure result with the given error and optional stack trace - static ProgressDialogResult failure(Object error, [StackTrace? stackTrace]) => Failure(error, stackTrace); + static ProgressDialogResult failure(Object error, + [StackTrace? stackTrace]) => + Failure(error, stackTrace); /// Creates a cancelled result static ProgressDialogResult cancelled() => Cancelled(); @@ -59,7 +61,8 @@ sealed class ProgressDialogResult { } /// Chains results by applying the provided function to success values - ProgressDialogResult flatMap(ProgressDialogResult Function(T value) fn) { + ProgressDialogResult flatMap( + ProgressDialogResult Function(T value) fn) { return switch (this) { Success(:final value) => fn(value), Failure(:final error, :final stackTrace) => Failure(error, stackTrace), @@ -79,7 +82,10 @@ class Success extends ProgressDialogResult { @override bool operator ==(Object other) => - identical(this, other) || other is Success && runtimeType == other.runtimeType && value == other.value; + identical(this, other) || + other is Success && + runtimeType == other.runtimeType && + value == other.value; @override int get hashCode => value.hashCode; @@ -98,7 +104,10 @@ class Failure extends ProgressDialogResult { @override bool operator ==(Object other) => identical(this, other) || - other is Failure && runtimeType == other.runtimeType && error == other.error && stackTrace == other.stackTrace; + other is Failure && + runtimeType == other.runtimeType && + error == other.error && + stackTrace == other.stackTrace; @override int get hashCode => error.hashCode ^ stackTrace.hashCode; @@ -121,7 +130,9 @@ class Cancelled extends ProgressDialogResult { bool get isCancelled => true; @override - bool operator ==(Object other) => identical(this, other) || other is Cancelled && runtimeType == other.runtimeType; + bool operator ==(Object other) => + identical(this, other) || + other is Cancelled && runtimeType == other.runtimeType; @override int get hashCode => runtimeType.hashCode; @@ -135,5 +146,6 @@ class ProgressDialogCancelledException implements Exception { const ProgressDialogCancelledException(); @override - String toString() => 'ProgressDialogCancelledException: the progress dialog was dismissed before the task completed'; + String toString() => + 'ProgressDialogCancelledException: the progress dialog was dismissed before the task completed'; } diff --git a/lib/src/show_progress_dialog.dart b/lib/src/show_progress_dialog.dart index 8af66a7..9c7451e 100644 --- a/lib/src/show_progress_dialog.dart +++ b/lib/src/show_progress_dialog.dart @@ -53,7 +53,10 @@ class _Outcome { w.NavigatorState navigator, w.Route> route, ) { - navigator.push>(route).then((result) => settle(result ?? Cancelled())).ignore(); + navigator + .push>(route) + .then((result) => settle(result ?? Cancelled())) + .ignore(); // If the navigator is disposed before the route ever builds, the dialog's // onDispose never fires because its state was never created. diff --git a/pubspec.yaml b/pubspec.yaml index a2a4fb3..1a996c1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: flutter_future_progress_dialog description: Show a progress dialog while a Future runs and return the result. Supports Material, Cupertino, and adaptive styles with type-safe error handling. -version: 1.5.0 +version: 2.0.0 repository: https://github.com/nerdy-pro/flutter-progress-dialog issue_tracker: https://github.com/nerdy-pro/flutter-progress-dialog/issues diff --git a/test/progress_dialog_test.dart b/test/progress_dialog_test.dart index c5b8100..a0a4bb3 100644 --- a/test/progress_dialog_test.dart +++ b/test/progress_dialog_test.dart @@ -7,7 +7,8 @@ import 'package:flutter_future_progress_dialog/flutter_future_progress_dialog.da import 'package:flutter_test/flutter_test.dart'; void main() { - testWidgets('showProgressDialog completes with some value', (WidgetTester tester) async { + testWidgets('showProgressDialog completes with some value', + (WidgetTester tester) async { final completer = Completer?>(); dialogTest(BuildContext context) async { final result = await showProgressDialog( @@ -166,7 +167,8 @@ void main() { }, ); - testWidgets('showProgressDialog completes with some error', (WidgetTester tester) async { + testWidgets('showProgressDialog completes with some error', + (WidgetTester tester) async { final completer = Completer?>(); dialogTest(BuildContext context) async { final result = await showProgressDialog( @@ -443,7 +445,8 @@ void main() { }); test('ProgressDialogResult.cancelled builds a Cancelled', () { - expect(ProgressDialogResult.cancelled(), isA>()); + expect( + ProgressDialogResult.cancelled(), isA>()); }); }); @@ -452,7 +455,8 @@ void main() { // which is never null when the task delivers, so Success(null) survives the // `result ?? Cancelled()` fallback intact. group('nullable task results', () { - testWidgets('showProgressDialog returns Success for a Future task', (WidgetTester tester) async { + testWidgets('showProgressDialog returns Success for a Future task', + (WidgetTester tester) async { final completer = Completer>(); await tester.pumpWidget( @@ -464,7 +468,8 @@ void main() { showProgressDialog( context: context, future: () async { - await Future.delayed(const Duration(milliseconds: 100)); + await Future.delayed( + const Duration(milliseconds: 100)); return null; }, ).then(completer.complete); @@ -486,7 +491,9 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('showProgressDialog reports a null value as Success, not Cancelled', (WidgetTester tester) async { + testWidgets( + 'showProgressDialog reports a null value as Success, not Cancelled', + (WidgetTester tester) async { final completer = Completer>(); await tester.pumpWidget( @@ -498,7 +505,8 @@ void main() { showProgressDialog( context: context, future: () async { - await Future.delayed(const Duration(milliseconds: 100)); + await Future.delayed( + const Duration(milliseconds: 100)); return null; }, ).then(completer.complete); @@ -518,7 +526,9 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('showCupertinoProgressDialog returns Success for a Future task', (WidgetTester tester) async { + testWidgets( + 'showCupertinoProgressDialog returns Success for a Future task', + (WidgetTester tester) async { final completer = Completer>(); await tester.pumpWidget( @@ -529,7 +539,8 @@ void main() { showCupertinoProgressDialog( context: context, future: () async { - await Future.delayed(const Duration(milliseconds: 100)); + await Future.delayed( + const Duration(milliseconds: 100)); return null; }, ).then(completer.complete); @@ -547,7 +558,9 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('showProgressDialog returns Cancelled when a nullable task is dismissed', (WidgetTester tester) async { + testWidgets( + 'showProgressDialog returns Cancelled when a nullable task is dismissed', + (WidgetTester tester) async { final taskCompleter = Completer(); final completer = Completer>(); @@ -585,7 +598,8 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('showProgressDialog surfaces errors from a Future task', (WidgetTester tester) async { + testWidgets('showProgressDialog surfaces errors from a Future task', + (WidgetTester tester) async { final completer = Completer>(); await tester.pumpWidget( @@ -597,7 +611,8 @@ void main() { showProgressDialog( context: context, future: () async { - await Future.delayed(const Duration(milliseconds: 100)); + await Future.delayed( + const Duration(milliseconds: 100)); throw 'boom'; }, ).then(completer.complete); @@ -619,7 +634,8 @@ void main() { // Realistic usage: no explicit type argument, T comes from the task's own // Future signature. runtimeType is checked exactly because // Success would satisfy isA>() by covariance. - testWidgets('showProgressDialog infers String? from the task signature', (WidgetTester tester) async { + testWidgets('showProgressDialog infers String? from the task signature', + (WidgetTester tester) async { final completer = Completer>(); Future nullableTask() async { @@ -655,7 +671,8 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('showProgressDialog carries a non-null value on a String? task', (WidgetTester tester) async { + testWidgets('showProgressDialog carries a non-null value on a String? task', + (WidgetTester tester) async { final completer = Completer>(); Future nullableTask() async { @@ -689,7 +706,9 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('showProgressDialog infers String? when a nullable task is dismissed', (WidgetTester tester) async { + testWidgets( + 'showProgressDialog infers String? when a nullable task is dismissed', + (WidgetTester tester) async { final taskCompleter = Completer(); final completer = Completer>(); @@ -754,7 +773,8 @@ void main() { // theme, the way Flutter's own showAdaptiveDialog does — not the host OS the // process happens to be running on. group('adaptive platform selection', () { - Future> pumpAdaptive(WidgetTester tester, TargetPlatform platform) async { + Future> pumpAdaptive( + WidgetTester tester, TargetPlatform platform) async { final task = Completer(); await tester.pumpWidget( @@ -783,7 +803,8 @@ void main() { } for (final platform in [TargetPlatform.iOS, TargetPlatform.macOS]) { - testWidgets('uses a Cupertino dialog on $platform', (WidgetTester tester) async { + testWidgets('uses a Cupertino dialog on $platform', + (WidgetTester tester) async { final task = await pumpAdaptive(tester, platform); expect(find.byType(CupertinoActivityIndicator), findsOneWidget); @@ -801,7 +822,8 @@ void main() { TargetPlatform.linux, TargetPlatform.windows, ]) { - testWidgets('uses a Material dialog on $platform', (WidgetTester tester) async { + testWidgets('uses a Material dialog on $platform', + (WidgetTester tester) async { final task = await pumpAdaptive(tester, platform); expect(find.byType(CircularProgressIndicator), findsOneWidget); @@ -813,7 +835,8 @@ void main() { }); } - testWidgets('returns a result like the non-adaptive variants', (WidgetTester tester) async { + testWidgets('returns a result like the non-adaptive variants', + (WidgetTester tester) async { final completer = Completer>(); await tester.pumpWidget( @@ -826,7 +849,8 @@ void main() { showAdaptiveProgressDialog( context: context, future: () async { - await Future.delayed(const Duration(milliseconds: 100)); + await Future.delayed( + const Duration(milliseconds: 100)); return 'ok'; }, ).then(completer.complete);