diff --git a/CHANGELOG.md b/CHANGELOG.md index eac6383..1884401 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,18 @@ +## Unreleased + +**Breaking:** `ProgressDialogResult` is sealed and now has a third variant, so +exhaustive `switch` statements over it need a `Cancelled` case added. + +* 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 + ## 1.5.0 * Fixed crash when dialog context unmounts before task completes diff --git a/README.md b/README.md index bfa4b61..aaa7faf 100644 --- a/README.md +++ b/README.md @@ -11,12 +11,13 @@ Developed and maintained by [Nerdy Pro](https://nerdy.pro). ## Features -- Show a non-dismissible progress dialog while a Future is running +- Show a modal progress dialog while a Future is running - Material and Cupertino dialog styles - Adaptive dialog that automatically matches the host platform - Custom dialog UI via a builder parameter -- Type-safe `ProgressDialogResult` with `Success` and `Failure` variants +- Type-safe `ProgressDialogResult` with `Success`, `Failure`, and `Cancelled` variants - Error handling with stack traces +- Dismissal (back button, custom cancel button) reported as `Cancelled` rather than crashing ## Installation @@ -51,6 +52,8 @@ Future onButtonPressed(BuildContext context) async { print('Got: $value'); case Failure(:final error): print('Error: $error'); + case Cancelled(): + print('Dismissed before the task finished'); } } ``` @@ -89,10 +92,11 @@ final result = await showProgressDialog( ### Handling results -`ProgressDialogResult` is a sealed class with two variants: +`ProgressDialogResult` is a sealed class with three variants: - `Success` — contains the `value` returned by the task - `Failure` — contains the `error` and optional `stackTrace` +- `Cancelled` — the dialog was dismissed before the task delivered a result ```dart switch (result) { @@ -102,16 +106,31 @@ switch (result) { case Failure(:final error, :final stackTrace): // Handle the error break; + case Cancelled(): + // The user dismissed the dialog + break; } ``` +### Cancellation + +The dialog is modal and ignores taps on the barrier, but it is still a route: the +Android back button pops it, and a custom `builder` may render its own cancel +button that calls `Navigator.pop`. Either way the call returns `Cancelled`. + +The task itself is **not** interrupted — Dart futures cannot be cancelled. It runs +to completion in the background and its result, value or error, is discarded. If +the task needs to stop doing work, give it its own cancellation mechanism. + You can also use convenience methods: ```dart -result.isSuccess; // true if Success -result.isError; // true if Failure -result.unwrap(); // returns value or throws error -result.map((v) => v.toString()); // transforms Success value +result.isSuccess; // true if Success +result.isError; // true if Failure +result.isCancelled; // true if Cancelled +result.unwrap(); // returns value, throws the error, or throws + // ProgressDialogCancelledException if cancelled +result.map((v) => v.toString()); // transforms Success value, passes the rest through ``` ## API reference diff --git a/example/lib/main.dart b/example/lib/main.dart index 04375f9..7e5725e 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -27,6 +27,11 @@ Future _successTask() async { return 'Task completed'; } +Future _slowTask() async { + await Future.delayed(const Duration(seconds: 10)); + return 'Task completed'; +} + Future _failingTask() async { await Future.delayed(const Duration(seconds: 2)); throw Exception('Something went wrong'); @@ -39,6 +44,7 @@ class ExamplePage extends StatelessWidget { final message = switch (result) { Success(:final value) => value, Failure(:final error) => 'Error: $error', + Cancelled() => 'Cancelled', }; ScaffoldMessenger.of(context) @@ -86,6 +92,34 @@ class ExamplePage extends StatelessWidget { _showResult(context, result); } + // A custom dialog that offers its own cancel button. Popping the route + // resolves the call with Cancelled; the task keeps running in the background. + Future _onCancellable(BuildContext context) async { + final result = await showProgressDialog( + context: context, + future: () => _slowTask(), + builder: (dialogContext) => Dialog( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + spacing: 16, + children: [ + const CircularProgressIndicator(), + const Text('This takes a while...'), + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('Cancel'), + ), + ], + ), + ), + ), + ); + if (!context.mounted) return; + _showResult(context, result); + } + Future _onFailure(BuildContext context) async { final result = await showProgressDialog( context: context, @@ -119,6 +153,10 @@ class ExamplePage extends StatelessWidget { onPressed: () => _onCustom(context), child: const Text('Custom dialog'), ), + FilledButton( + onPressed: () => _onCancellable(context), + child: const Text('Cancellable'), + ), FilledButton.tonal( onPressed: () => _onFailure(context), child: const Text('Failure'), diff --git a/lib/src/result.dart b/lib/src/result.dart index fbf6803..e57823a 100644 --- a/lib/src/result.dart +++ b/lib/src/result.dart @@ -1,6 +1,7 @@ /// The result of an asynchronous operation shown in a progress dialog. /// -/// Either a [Success] containing the value, or a [Failure] containing the error. +/// One of [Success] containing the value, [Failure] containing the error, or +/// [Cancelled] when the dialog was dismissed before the task delivered a result. /// /// Example usage: /// ```dart @@ -10,9 +11,13 @@ /// print('Success: $value'); /// case Failure(error: var error, stackTrace: var trace): /// print('Error: $error'); +/// case Cancelled(): +/// print('Dismissed before the task finished'); /// } /// ``` sealed class ProgressDialogResult { + const ProgressDialogResult(); + /// Returns true if this result represents an error/failure case, false otherwise. /// This is equivalent to checking if the result is an instance of [Failure]. bool get isError => this is Failure; @@ -21,17 +26,26 @@ sealed class ProgressDialogResult { /// This is equivalent to checking if the result is an instance of [Success]. bool get isSuccess => this is Success; + /// Returns true if the dialog was dismissed before the task delivered a result. + /// This is equivalent to checking if the result is an instance of [Cancelled]. + bool get isCancelled => this is Cancelled; + /// Creates a success result with the given value 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); - /// Unwraps the result, returning the success value or throwing the error + /// Creates a cancelled result + static ProgressDialogResult cancelled() => Cancelled(); + + /// Unwraps the result, returning the success value, throwing the error, or + /// throwing a [ProgressDialogCancelledException] if the dialog was dismissed T unwrap() { return switch (this) { Success(:final value) => value, Failure(:final error) => throw error, + Cancelled() => throw const ProgressDialogCancelledException(), }; } @@ -40,6 +54,7 @@ sealed class ProgressDialogResult { return switch (this) { Success(:final value) => Success(fn(value)), Failure(:final error, :final stackTrace) => Failure(error, stackTrace), + Cancelled() => Cancelled(), }; } @@ -48,6 +63,7 @@ sealed class ProgressDialogResult { return switch (this) { Success(:final value) => fn(value), Failure(:final error, :final stackTrace) => Failure(error, stackTrace), + Cancelled() => Cancelled(), }; } } @@ -59,7 +75,7 @@ class Success extends ProgressDialogResult { @override bool get isError => false; - Success(this.value); + const Success(this.value); @override bool operator ==(Object other) => @@ -77,7 +93,7 @@ class Failure extends ProgressDialogResult { @override bool get isError => true; - Failure(this.error, [this.stackTrace]); + const Failure(this.error, [this.stackTrace]); @override bool operator ==(Object other) => @@ -87,3 +103,37 @@ class Failure extends ProgressDialogResult { @override int get hashCode => error.hashCode ^ stackTrace.hashCode; } + +/// A cancelled result: the dialog was dismissed before the task's result could +/// be delivered — for example by the Android back button, or by a custom +/// `builder` that calls `Navigator.pop`. +/// +/// The task itself is **not** interrupted. Dart futures cannot be cancelled, so +/// the work keeps running to completion in the background and its result — value +/// or error — is discarded. +class Cancelled extends ProgressDialogResult { + const Cancelled(); + + @override + bool get isError => false; + + @override + bool get isCancelled => true; + + @override + bool operator ==(Object other) => identical(this, other) || other is Cancelled && runtimeType == other.runtimeType; + + @override + int get hashCode => runtimeType.hashCode; +} + +/// Thrown by [ProgressDialogResult.unwrap] when the result is [Cancelled]. +/// +/// A cancelled result carries neither a value to return nor an error to rethrow, +/// so `unwrap` throws this instead. +class ProgressDialogCancelledException implements Exception { + const ProgressDialogCancelledException(); + + @override + 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 d73201c..d1694b1 100644 --- a/lib/src/show_progress_dialog.dart +++ b/lib/src/show_progress_dialog.dart @@ -41,8 +41,11 @@ Future _callback( /// * [fullscreenDialog] - Whether this dialog is a fullscreen dialog /// * [animationStyle] - Style of the dialog animation /// -/// Returns a [ProgressDialogResult] containing either the successful result value -/// or error details if the future fails. +/// Returns a [ProgressDialogResult]: [Success] with the value, [Failure] with the +/// error details if the future fails, or [Cancelled] if the dialog is dismissed +/// before the task delivers a result — for example by the Android back button, or +/// by a custom [builder] that calls `Navigator.pop`. The task itself is not +/// interrupted in that case; it runs to completion and its result is discarded. /// /// Example: /// ```dart @@ -58,6 +61,9 @@ Future _callback( /// case Failure(error: final error): /// print('Task failed: $error'); /// break; +/// case Cancelled(): +/// print('Dialog dismissed before the task finished'); +/// break; /// } /// ``` Future> showProgressDialog({ @@ -107,7 +113,7 @@ Future> showProgressDialog({ final result = await navigator.push>(route); - return result!; + return result ?? Cancelled(); } /// Shows a Cupertino-styled progress dialog while executing an asynchronous task. @@ -125,8 +131,11 @@ Future> showProgressDialog({ /// * [barrierColor] - Color of the modal barrier behind the dialog /// * [requestFocus] - Whether the dialog should request focus when opened /// -/// Returns a [ProgressDialogResult] containing either the successful result value -/// or error details if the future fails. +/// Returns a [ProgressDialogResult]: [Success] with the value, [Failure] with the +/// error details if the future fails, or [Cancelled] if the dialog is dismissed +/// before the task delivers a result — for example by the Android back button, or +/// by a custom [builder] that calls `Navigator.pop`. The task itself is not +/// interrupted in that case; it runs to completion and its result is discarded. /// /// Example: /// ```dart @@ -142,6 +151,9 @@ Future> showProgressDialog({ /// case Failure(error: final error): /// print('Task failed: $error'); /// break; +/// case Cancelled(): +/// print('Dialog dismissed before the task finished'); +/// break; /// } /// ``` Future> showCupertinoProgressDialog({ @@ -171,7 +183,7 @@ Future> showCupertinoProgressDialog({ requestFocus: requestFocus, ); final result = await navigator.push>(route); - return result!; + return result ?? Cancelled(); } /// Shows a platform-adaptive progress dialog while executing an asynchronous task. @@ -191,8 +203,11 @@ Future> showCupertinoProgressDialog({ /// * [useSafeArea] - Whether to respect system UI safe areas, defaults to true /// * [requestFocus] - Whether the dialog should request focus when opened /// -/// Returns a [ProgressDialogResult] containing either the successful result value -/// or error details if the future fails. +/// Returns a [ProgressDialogResult]: [Success] with the value, [Failure] with the +/// error details if the future fails, or [Cancelled] if the dialog is dismissed +/// before the task delivers a result — for example by the Android back button, or +/// by a custom [builder] that calls `Navigator.pop`. The task itself is not +/// interrupted in that case; it runs to completion and its result is discarded. /// /// Example: /// ```dart @@ -208,6 +223,9 @@ Future> showCupertinoProgressDialog({ /// case Failure(error: final error): /// print('Task failed: $error'); /// break; +/// case Cancelled(): +/// print('Dialog dismissed before the task finished'); +/// break; /// } /// ``` Future> showAdaptiveProgressDialog({ diff --git a/test/progress_dialog_test.dart b/test/progress_dialog_test.dart index 9d4ca24..9b26706 100644 --- a/test/progress_dialog_test.dart +++ b/test/progress_dialog_test.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter_future_progress_dialog/flutter_future_progress_dialog.dart'; @@ -141,4 +142,552 @@ void main() { expect(result.runtimeType, Failure); expect((result as Failure).error, 'error'); }); + + // Regression test: the dialog is modal but not un-poppable. The Android + // system back button pops the DialogRoute, push() resolves with null, and + // the old `result!` threw "Null check operator used on a null value". + testWidgets( + 'showProgressDialog returns Cancelled when the back button dismisses the dialog', + (WidgetTester tester) async { + final taskCompleter = Completer(); + final completer = Completer>(); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Builder( + builder: (context) { + SchedulerBinding.instance.addPostFrameCallback((_) { + showProgressDialog( + context: context, + future: () => taskCompleter.future, + ).then(completer.complete); + }); + return Container(); + }, + ), + ), + ), + ); + await tester.pump(); + await tester.pump(); + + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + + expect(await completer.future, isA>()); + expect(tester.takeException(), isNull); + + // The task is not interrupted — it finishes and its value is discarded. + taskCompleter.complete('ok'); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }, + ); + + // A custom `builder` can render its own cancel affordance, which pops the + // route out from under the in-flight task. + testWidgets( + 'showProgressDialog returns Cancelled when a custom builder pops the route', + (WidgetTester tester) async { + final taskCompleter = Completer(); + final completer = Completer>(); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Builder( + builder: (context) { + SchedulerBinding.instance.addPostFrameCallback((_) { + showProgressDialog( + context: context, + future: () => taskCompleter.future, + builder: (dialogContext) => Dialog( + child: TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('Cancel'), + ), + ), + ).then(completer.complete); + }); + return Container(); + }, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + expect(await completer.future, isA>()); + expect(tester.takeException(), isNull); + + taskCompleter.complete('ok'); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'showCupertinoProgressDialog returns Cancelled when the back button dismisses the dialog', + (WidgetTester tester) async { + final taskCompleter = Completer(); + final completer = Completer>(); + + await tester.pumpWidget( + CupertinoApp( + home: Builder( + builder: (context) { + SchedulerBinding.instance.addPostFrameCallback((_) { + showCupertinoProgressDialog( + context: context, + future: () => taskCompleter.future, + ).then(completer.complete); + }); + return Container(); + }, + ), + ), + ); + await tester.pump(); + await tester.pump(); + + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + + expect(await completer.future, isA>()); + expect(tester.takeException(), isNull); + + taskCompleter.complete('ok'); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }, + ); + + // The narrow window where the route is mid-dismiss but not yet gone: the + // callback must not try to remove a route the navigator is already popping. + testWidgets( + 'showProgressDialog returns Cancelled when the task completes during the dismiss animation', + (WidgetTester tester) async { + final taskCompleter = Completer(); + final completer = Completer>(); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Builder( + builder: (context) { + SchedulerBinding.instance.addPostFrameCallback((_) { + showProgressDialog( + context: context, + future: () => taskCompleter.future, + ).then(completer.complete); + }); + return Container(); + }, + ), + ), + ), + ); + await tester.pump(); + await tester.pump(); + + // Let the navigator process the pop, then resolve the task while the + // dismiss animation is still running. + await tester.binding.handlePopRoute(); + await tester.pump(); + taskCompleter.complete('ok'); + await tester.pump(const Duration(milliseconds: 50)); + await tester.pumpAndSettle(); + + expect(await completer.future, isA>()); + expect(tester.takeException(), isNull); + }, + ); + + // The opposite side of that race: a back press that has been requested but + // not yet delivered does not invalidate a task that finishes first. The + // dialog closes with the value it actually produced. + testWidgets( + 'showProgressDialog returns Success when the task wins the race against a pending pop', + (WidgetTester tester) async { + final taskCompleter = Completer(); + final completer = Completer>(); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Builder( + builder: (context) { + SchedulerBinding.instance.addPostFrameCallback((_) { + showProgressDialog( + context: context, + future: () => taskCompleter.future, + ).then(completer.complete); + }); + return Container(); + }, + ), + ), + ), + ); + await tester.pump(); + await tester.pump(); + + tester.binding.handlePopRoute().ignore(); + taskCompleter.complete('ok'); + await tester.pumpAndSettle(); + + expect(await completer.future, const Success('ok')); + expect(tester.takeException(), isNull); + }, + ); + + group('Cancelled', () { + test('reports itself as neither success nor error', () { + const result = Cancelled(); + expect(result.isCancelled, isTrue); + expect(result.isSuccess, isFalse); + expect(result.isError, isFalse); + }); + + test('Success and Failure are not cancelled', () { + expect(const Success('ok').isCancelled, isFalse); + expect(const Failure('boom').isCancelled, isFalse); + }); + + test('unwrap throws ProgressDialogCancelledException', () { + expect( + () => const Cancelled().unwrap(), + throwsA(isA()), + ); + }); + + test('map and flatMap pass cancellation through', () { + const result = Cancelled(); + expect(result.map((v) => v.length), isA>()); + expect( + result.flatMap((v) => Success(v.length)), + isA>(), + ); + }); + + test('instances of the same type are equal', () { + expect(const Cancelled(), equals(const Cancelled())); + expect( + const Cancelled().hashCode, + equals(const Cancelled().hashCode), + ); + expect(const Cancelled(), isNot(equals(const Cancelled()))); + }); + + test('ProgressDialogResult.cancelled builds a Cancelled', () { + expect(ProgressDialogResult.cancelled(), isA>()); + }); + }); + + // A task whose value is legitimately null must not be confused with the + // absence of a result. `push` resolves with the ProgressDialogResult wrapper, + // 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 { + final completer = Completer>(); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Builder( + builder: (context) { + SchedulerBinding.instance.addPostFrameCallback((_) { + showProgressDialog( + context: context, + future: () async { + await Future.delayed(const Duration(milliseconds: 100)); + return null; + }, + ).then(completer.complete); + }); + return Container(); + }, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final result = await completer.future; + expect(result, isA>()); + expect((result as Success).value, isNull); + expect(result.isSuccess, isTrue); + expect(result.isCancelled, isFalse); + expect(result.unwrap(), isNull); + expect(tester.takeException(), isNull); + }); + + testWidgets('showProgressDialog reports a null value as Success, not Cancelled', (WidgetTester tester) async { + final completer = Completer>(); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Builder( + builder: (context) { + SchedulerBinding.instance.addPostFrameCallback((_) { + showProgressDialog( + context: context, + future: () async { + await Future.delayed(const Duration(milliseconds: 100)); + return null; + }, + ).then(completer.complete); + }); + return Container(); + }, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final result = await completer.future; + expect(result, const Success(null)); + expect(result.isCancelled, isFalse); + expect(result.unwrap(), isNull); + expect(tester.takeException(), isNull); + }); + + testWidgets('showCupertinoProgressDialog returns Success for a Future task', (WidgetTester tester) async { + final completer = Completer>(); + + await tester.pumpWidget( + CupertinoApp( + home: Builder( + builder: (context) { + SchedulerBinding.instance.addPostFrameCallback((_) { + showCupertinoProgressDialog( + context: context, + future: () async { + await Future.delayed(const Duration(milliseconds: 100)); + return null; + }, + ).then(completer.complete); + }); + return Container(); + }, + ), + ), + ); + await tester.pumpAndSettle(); + + final result = await completer.future; + expect(result, isA>()); + expect((result as Success).value, isNull); + expect(tester.takeException(), isNull); + }); + + testWidgets('showProgressDialog returns Cancelled when a nullable task is dismissed', (WidgetTester tester) async { + final taskCompleter = Completer(); + final completer = Completer>(); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Builder( + builder: (context) { + SchedulerBinding.instance.addPostFrameCallback((_) { + showProgressDialog( + context: context, + future: () => taskCompleter.future, + ).then(completer.complete); + }); + return Container(); + }, + ), + ), + ), + ); + await tester.pump(); + await tester.pump(); + + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + + final result = await completer.future; + expect(result, isA>()); + expect(result.isCancelled, isTrue); + expect(result.isSuccess, isFalse); + expect(tester.takeException(), isNull); + + taskCompleter.complete(null); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }); + + testWidgets('showProgressDialog surfaces errors from a Future task', (WidgetTester tester) async { + final completer = Completer>(); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Builder( + builder: (context) { + SchedulerBinding.instance.addPostFrameCallback((_) { + showProgressDialog( + context: context, + future: () async { + await Future.delayed(const Duration(milliseconds: 100)); + throw 'boom'; + }, + ).then(completer.complete); + }); + return Container(); + }, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final result = await completer.future; + expect(result, isA>()); + expect((result as Failure).error, 'boom'); + expect(tester.takeException(), isNull); + }); + + // 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 { + final completer = Completer>(); + + Future nullableTask() async { + await Future.delayed(const Duration(milliseconds: 100)); + return null; + } + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Builder( + builder: (context) { + SchedulerBinding.instance.addPostFrameCallback((_) { + showProgressDialog( + context: context, + future: nullableTask, + ).then(completer.complete); + }); + return Container(); + }, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final result = await completer.future; + expect(result.runtimeType, Success); + expect((result as Success).value, isNull); + expect(result.isSuccess, isTrue); + expect(result.isCancelled, isFalse); + expect(result.unwrap(), isNull); + expect(tester.takeException(), isNull); + }); + + testWidgets('showProgressDialog carries a non-null value on a String? task', (WidgetTester tester) async { + final completer = Completer>(); + + Future nullableTask() async { + await Future.delayed(const Duration(milliseconds: 100)); + return 'ok'; + } + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Builder( + builder: (context) { + SchedulerBinding.instance.addPostFrameCallback((_) { + showProgressDialog( + context: context, + future: nullableTask, + ).then(completer.complete); + }); + return Container(); + }, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final result = await completer.future; + expect(result.runtimeType, Success); + expect(result, const Success('ok')); + expect(result.unwrap(), 'ok'); + expect(tester.takeException(), isNull); + }); + + testWidgets('showProgressDialog infers String? when a nullable task is dismissed', (WidgetTester tester) async { + final taskCompleter = Completer(); + final completer = Completer>(); + + Future nullableTask() => taskCompleter.future; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Builder( + builder: (context) { + SchedulerBinding.instance.addPostFrameCallback((_) { + showProgressDialog( + context: context, + future: nullableTask, + ).then(completer.complete); + }); + return Container(); + }, + ), + ), + ), + ); + await tester.pump(); + await tester.pump(); + + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + + final result = await completer.future; + expect(result.runtimeType, Cancelled); + expect(result.isCancelled, isTrue); + expect(tester.takeException(), isNull); + + taskCompleter.complete(null); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }); + + test('a null Success is distinguishable from Cancelled', () { + const success = Success(null); + const cancelled = Cancelled(); + + expect(success, isNot(equals(cancelled))); + expect(success.isSuccess, isTrue); + expect(success.isCancelled, isFalse); + expect(cancelled.isSuccess, isFalse); + expect(cancelled.isCancelled, isTrue); + }); + + test('map and flatMap carry a null value through', () { + const success = Success(null); + + expect(success.map((v) => v?.length), const Success(null)); + expect( + success.flatMap((v) => Success(v?.length)), + const Success(null), + ); + }); + }); }