[image_picker_web] Listens to file input cancel event. (#4453)

## Changes

This PR listens to the `cancel` event from the `input type=file` used by the web implementation of the image_picker plugin, so apps don't end up endlessly awaiting for a file that will never come **in modern browsers** (Chrome 113, Safari 16.4, or newer). _Same API as https://github.com/flutter/packages/pull/3683._

Additionally, this PR:

* Removes all code and tests mentioning `PickedFile`. (Deprecated years ago, and unused since https://github.com/flutter/packages/pull/4285) **(Breaking change)**
* Updates README to mention `XFile` which is the current return type of the package.
* Updates the dependency on `image_picker_platform_interface` to `^2.9.0`.
  * Implements all non-deprecated methods from the interface, and makes deprecated methods use the fresh ones.
  * Updates tests.

### Issues

* Fixes https://github.com/flutter/flutter/issues/92176

### Testing

* Added integration testing coverage for the 'cancel' event.
* Tested manually in Chrome with the example app running on web.
This commit is contained in:
David Iglesias
2023-08-04 08:08:05 -07:00
committed by GitHub
parent bf8e503898
commit ce53da1bd7
5 changed files with 304 additions and 190 deletions

View File

@ -1,3 +1,10 @@
## 3.0.0
* **BREAKING CHANGE:** Removes all code and tests mentioning `PickedFile`.
* Listens to `cancel` event on file selection. When the selection is canceled:
* `Future<XFile?>` methods return `null`
* `Future<List<XFile>>` methods return an empty list.
## 2.2.0 ## 2.2.0
* Adds `getMedia` method. * Adds `getMedia` method.

View File

@ -4,23 +4,12 @@ A web implementation of [`image_picker`][1].
## Limitations on the web platform ## Limitations on the web platform
Since Web Browsers don't offer direct access to their users' file system, ### `XFile`
this plugin provides a `PickedFile` abstraction to make access uniform
across platforms.
The web version of the plugin puts network-accessible URIs as the `path` This plugin uses `XFile` objects to abstract files picked/created by the user.
in the returned `PickedFile`.
### URL.createObjectURL() Read more about `XFile` on the web in
[`package:cross_file`'s README](https://pub.dev/packages/cross_file).
The `PickedFile` object in web is backed by [`URL.createObjectUrl` Web API](https://developer.mozilla.org/en-US/docs/Web/API/URL/createObjectURL),
which is reasonably well supported across all browsers:
![Data on support for the bloburls feature across the major browsers from caniuse.com](https://caniuse.bitsofco.de/image/bloburls.png)
However, the returned `path` attribute of the `PickedFile` points to a `network` resource, and not a
local path in your users' drive. See **Use the plugin** below for some examples on how to use this
return value in a cross-platform way.
### input file "accept" ### input file "accept"
@ -42,11 +31,26 @@ In order to "take a photo", some mobile browsers offer a [`capture` attribute](h
Each browser may implement `capture` any way they please, so it may (or may not) make a Each browser may implement `capture` any way they please, so it may (or may not) make a
difference in your users' experience. difference in your users' experience.
### pickImage() ### input file "cancel"
The arguments `maxWidth`, `maxHeight` and `imageQuality` are not supported for gif images.
The argument `imageQuality` only works for jpeg and webp images. The [`cancel` event](https://caniuse.com/mdn-api_htmlinputelement_cancel_event)
used by the plugin to detect when users close the file selector without picking
a file is relatively new, and will only work in recent browsers.
### `ImagePickerOptions` support
The `ImagePickerOptions` configuration object allows passing resize (`maxWidth`,
`maxHeight`) and quality (`imageQuality`) parameters to some methods of this
plugin, which in other platforms control how selected images are resized or
re-encoded.
On the web:
* `maxWidth`, `maxHeight` and `imageQuality` are not supported for `gif` images.
* `imageQuality` only affects `jpg` and `webp` images.
### `getVideo()`
### pickVideo()
The argument `maxDuration` is not supported on the web. The argument `maxDuration` is not supported on the web.
## Usage ## Usage
@ -65,8 +69,8 @@ should add it to your `pubspec.yaml` as usual.
You should be able to use `package:image_picker` _almost_ as normal. You should be able to use `package:image_picker` _almost_ as normal.
Once the user has picked a file, the returned `PickedFile` instance will contain a Once the user has picked a file, the returned `XFile` instance will contain a
`network`-accessible URL (pointing to a location within the browser). `network`-accessible `Blob` URL (pointing to a location within the browser).
The instance will also let you retrieve the bytes of the selected file across all platforms. The instance will also let you retrieve the bytes of the selected file across all platforms.

View File

@ -33,7 +33,9 @@ void main() {
plugin = ImagePickerPlugin(); plugin = ImagePickerPlugin();
}); });
testWidgets('Can select a file (Deprecated)', (WidgetTester tester) async { testWidgets('getImageFromSource can select a file', (
WidgetTester _,
) async {
final html.FileUploadInputElement mockInput = html.FileUploadInputElement(); final html.FileUploadInputElement mockInput = html.FileUploadInputElement();
final ImagePickerPluginTestOverrides overrides = final ImagePickerPluginTestOverrides overrides =
@ -44,29 +46,9 @@ void main() {
final ImagePickerPlugin plugin = ImagePickerPlugin(overrides: overrides); final ImagePickerPlugin plugin = ImagePickerPlugin(overrides: overrides);
// Init the pick file dialog... // Init the pick file dialog...
final Future<PickedFile> file = plugin.pickFile(); final Future<XFile?> image = plugin.getImageFromSource(
source: ImageSource.camera,
// Mock the browser behavior of selecting a file... );
mockInput.dispatchEvent(html.Event('change'));
// Now the file should be available
expect(file, completes);
// And readable
expect((await file).readAsBytes(), completion(isNotEmpty));
});
testWidgets('Can select a file', (WidgetTester tester) async {
final html.FileUploadInputElement mockInput = html.FileUploadInputElement();
final ImagePickerPluginTestOverrides overrides =
ImagePickerPluginTestOverrides()
..createInputElement = ((_, __) => mockInput)
..getMultipleFilesFromInput = ((_) => <html.File>[textFile]);
final ImagePickerPlugin plugin = ImagePickerPlugin(overrides: overrides);
// Init the pick file dialog...
final Future<XFile> image = plugin.getImage(source: ImageSource.camera);
// Mock the browser behavior of selecting a file... // Mock the browser behavior of selecting a file...
mockInput.dispatchEvent(html.Event('change')); mockInput.dispatchEvent(html.Event('change'));
@ -75,8 +57,9 @@ void main() {
expect(image, completes); expect(image, completes);
// And readable // And readable
final XFile file = await image; final XFile? file = await image;
expect(file.readAsBytes(), completion(isNotEmpty)); expect(file, isNotNull);
expect(file!.readAsBytes(), completion(isNotEmpty));
expect(file.name, textFile.name); expect(file.name, textFile.name);
expect(file.length(), completion(textFile.size)); expect(file.length(), completion(textFile.size));
expect(file.mimeType, textFile.type); expect(file.mimeType, textFile.type);
@ -87,8 +70,9 @@ void main() {
)); ));
}); });
testWidgets('getMultiImage can select multiple files', testWidgets('getMultiImageWithOptions can select multiple files', (
(WidgetTester tester) async { WidgetTester _,
) async {
final html.FileUploadInputElement mockInput = html.FileUploadInputElement(); final html.FileUploadInputElement mockInput = html.FileUploadInputElement();
final ImagePickerPluginTestOverrides overrides = final ImagePickerPluginTestOverrides overrides =
@ -100,7 +84,7 @@ void main() {
final ImagePickerPlugin plugin = ImagePickerPlugin(overrides: overrides); final ImagePickerPlugin plugin = ImagePickerPlugin(overrides: overrides);
// Init the pick file dialog... // Init the pick file dialog...
final Future<List<XFile>> files = plugin.getMultiImage(); final Future<List<XFile>> files = plugin.getMultiImageWithOptions();
// Mock the browser behavior of selecting a file... // Mock the browser behavior of selecting a file...
mockInput.dispatchEvent(html.Event('change')); mockInput.dispatchEvent(html.Event('change'));
@ -118,8 +102,7 @@ void main() {
expect(secondFile.length(), completion(secondTextFile.size)); expect(secondFile.length(), completion(secondTextFile.size));
}); });
testWidgets('getMedia can select multiple files', testWidgets('getMedia can select multiple files', (WidgetTester _) async {
(WidgetTester tester) async {
final html.FileUploadInputElement mockInput = html.FileUploadInputElement(); final html.FileUploadInputElement mockInput = html.FileUploadInputElement();
final ImagePickerPluginTestOverrides overrides = final ImagePickerPluginTestOverrides overrides =
@ -150,7 +133,72 @@ void main() {
expect(secondFile.length(), completion(secondTextFile.size)); expect(secondFile.length(), completion(secondTextFile.size));
}); });
// There's no good way of detecting when the user has "aborted" the selection. group('cancel event', () {
late html.FileUploadInputElement mockInput;
late ImagePickerPluginTestOverrides overrides;
late ImagePickerPlugin plugin;
setUp(() {
mockInput = html.FileUploadInputElement();
overrides = ImagePickerPluginTestOverrides()
..createInputElement = ((_, __) => mockInput)
..getMultipleFilesFromInput = ((_) => <html.File>[textFile]);
plugin = ImagePickerPlugin(overrides: overrides);
});
void mockCancel() {
mockInput.dispatchEvent(html.Event('cancel'));
}
testWidgets('getFiles - returns empty list', (WidgetTester _) async {
final Future<List<XFile>> files = plugin.getFiles();
mockCancel();
expect(files, completes);
expect(await files, isEmpty);
});
testWidgets('getMedia - returns empty list', (WidgetTester _) async {
final Future<List<XFile>?> files = plugin.getMedia(
options: const MediaOptions(
allowMultiple: true,
));
mockCancel();
expect(files, completes);
expect(await files, isEmpty);
});
testWidgets('getMultiImageWithOptions - returns empty list', (
WidgetTester _,
) async {
final Future<List<XFile>?> files = plugin.getMultiImageWithOptions();
mockCancel();
expect(files, completes);
expect(await files, isEmpty);
});
testWidgets('getImageFromSource - returns null', (WidgetTester _) async {
final Future<XFile?> file = plugin.getImageFromSource(
source: ImageSource.gallery,
);
mockCancel();
expect(file, completes);
expect(await file, isNull);
});
testWidgets('getVideo - returns null', (WidgetTester _) async {
final Future<XFile?> file = plugin.getVideo(
source: ImageSource.gallery,
);
mockCancel();
expect(file, completes);
expect(await file, isNull);
});
});
testWidgets('computeCaptureAttribute', (WidgetTester tester) async { testWidgets('computeCaptureAttribute', (WidgetTester tester) async {
expect( expect(
@ -208,4 +256,102 @@ void main() {
expect(input.attributes, contains('multiple')); expect(input.attributes, contains('multiple'));
}); });
}); });
group('Deprecated methods', () {
late html.FileUploadInputElement mockInput;
late ImagePickerPluginTestOverrides overrides;
late ImagePickerPlugin plugin;
setUp(() {
mockInput = html.FileUploadInputElement();
overrides = ImagePickerPluginTestOverrides()
..createInputElement = ((_, __) => mockInput)
..getMultipleFilesFromInput = ((_) => <html.File>[textFile]);
plugin = ImagePickerPlugin(overrides: overrides);
});
void mockCancel() {
mockInput.dispatchEvent(html.Event('cancel'));
}
void mockChange() {
mockInput.dispatchEvent(html.Event('change'));
}
group('getImage', () {
testWidgets('can select a file', (WidgetTester _) async {
// ignore: deprecated_member_use
final Future<XFile?> image = plugin.getImage(
source: ImageSource.camera,
);
// Mock the browser behavior when selecting a file...
mockChange();
// Now the file should be available
expect(image, completes);
// And readable
final XFile? file = await image;
expect(file, isNotNull);
expect(file!.readAsBytes(), completion(isNotEmpty));
expect(file.name, textFile.name);
expect(file.length(), completion(textFile.size));
expect(file.mimeType, textFile.type);
expect(
file.lastModified(),
completion(
DateTime.fromMillisecondsSinceEpoch(textFile.lastModified!),
));
});
testWidgets('returns null when canceled', (WidgetTester _) async {
// ignore: deprecated_member_use
final Future<XFile?> file = plugin.getImage(
source: ImageSource.gallery,
);
mockCancel();
expect(file, completes);
expect(await file, isNull);
});
});
group('getMultiImage', () {
testWidgets('can select multiple files', (WidgetTester _) async {
// Override the returned files...
overrides.getMultipleFilesFromInput =
(_) => <html.File>[textFile, secondTextFile];
// ignore: deprecated_member_use
final Future<List<XFile>> files = plugin.getMultiImage();
// Mock the browser behavior of selecting a file...
mockChange();
// Now the file should be available
expect(files, completes);
// And readable
expect((await files).first.readAsBytes(), completion(isNotEmpty));
// Peek into the second file...
final XFile secondFile = (await files).elementAt(1);
expect(secondFile.readAsBytes(), completion(isNotEmpty));
expect(secondFile.name, secondTextFile.name);
expect(secondFile.length(), completion(secondTextFile.size));
});
testWidgets('returns an empty list when canceled', (
WidgetTester _,
) async {
// ignore: deprecated_member_use
final Future<List<XFile>?> files = plugin.getMultiImage();
mockCancel();
expect(files, completes);
expect(await files, isEmpty);
});
});
});
} }

View File

@ -42,102 +42,47 @@ class ImagePickerPlugin extends ImagePickerPlatform {
ImagePickerPlatform.instance = ImagePickerPlugin(); ImagePickerPlatform.instance = ImagePickerPlugin();
} }
/// Returns a [PickedFile] with the image that was picked. /// Returns an [XFile] with the image that was picked, or `null` if no images were picked.
///
/// The `source` argument controls where the image comes from. This can
/// be either [ImageSource.camera] or [ImageSource.gallery].
///
/// Note that the `maxWidth`, `maxHeight` and `imageQuality` arguments are not supported on the web. If any of these arguments is supplied, it'll be silently ignored by the web version of the plugin.
///
/// Use `preferredCameraDevice` to specify the camera to use when the `source` is [ImageSource.camera].
/// The `preferredCameraDevice` is ignored when `source` is [ImageSource.gallery]. It is also ignored if the chosen camera is not supported on the device.
/// Defaults to [CameraDevice.rear].
///
/// If no images were picked, the return value is null.
@override @override
Future<PickedFile> pickImage({ Future<XFile?> getImageFromSource({
required ImageSource source, required ImageSource source,
double? maxWidth, ImagePickerOptions options = const ImagePickerOptions(),
double? maxHeight,
int? imageQuality,
CameraDevice preferredCameraDevice = CameraDevice.rear,
}) {
final String? capture =
computeCaptureAttribute(source, preferredCameraDevice);
return pickFile(accept: _kAcceptImageMimeType, capture: capture);
}
/// Returns a [PickedFile] containing the video that was picked.
///
/// The [source] argument controls where the video comes from. This can
/// be either [ImageSource.camera] or [ImageSource.gallery].
///
/// Note that the `maxDuration` argument is not supported on the web. If the argument is supplied, it'll be silently ignored by the web version of the plugin.
///
/// Use `preferredCameraDevice` to specify the camera to use when the `source` is [ImageSource.camera].
/// The `preferredCameraDevice` is ignored when `source` is [ImageSource.gallery]. It is also ignored if the chosen camera is not supported on the device.
/// Defaults to [CameraDevice.rear].
///
/// If no images were picked, the return value is null.
@override
Future<PickedFile> pickVideo({
required ImageSource source,
CameraDevice preferredCameraDevice = CameraDevice.rear,
Duration? maxDuration,
}) {
final String? capture =
computeCaptureAttribute(source, preferredCameraDevice);
return pickFile(accept: _kAcceptVideoMimeType, capture: capture);
}
/// Injects a file input with the specified accept+capture attributes, and
/// returns the PickedFile that the user selected locally.
///
/// `capture` is only supported in mobile browsers.
/// See https://caniuse.com/#feat=html-media-capture
@visibleForTesting
Future<PickedFile> pickFile({
String? accept,
String? capture,
}) {
final html.FileUploadInputElement input =
createInputElement(accept, capture) as html.FileUploadInputElement;
_injectAndActivate(input);
return _getSelectedFile(input);
}
/// Returns an [XFile] with the image that was picked.
///
/// The `source` argument controls where the image comes from. This can
/// be either [ImageSource.camera] or [ImageSource.gallery].
///
/// Note that the `maxWidth`, `maxHeight` and `imageQuality` arguments are not supported on the web. If any of these arguments is supplied, it'll be silently ignored by the web version of the plugin.
///
/// Use `preferredCameraDevice` to specify the camera to use when the `source` is [ImageSource.camera].
/// The `preferredCameraDevice` is ignored when `source` is [ImageSource.gallery]. It is also ignored if the chosen camera is not supported on the device.
/// Defaults to [CameraDevice.rear].
///
/// If no images were picked, the return value is null.
@override
Future<XFile> getImage({
required ImageSource source,
double? maxWidth,
double? maxHeight,
int? imageQuality,
CameraDevice preferredCameraDevice = CameraDevice.rear,
}) async { }) async {
final String? capture = final String? capture =
computeCaptureAttribute(source, preferredCameraDevice); computeCaptureAttribute(source, options.preferredCameraDevice);
final List<XFile> files = await getFiles( final List<XFile> files = await getFiles(
accept: _kAcceptImageMimeType, accept: _kAcceptImageMimeType,
capture: capture, capture: capture,
); );
return _imageResizer.resizeImageIfNeeded( return files.isEmpty
files.first, ? null
maxWidth, : _imageResizer.resizeImageIfNeeded(
maxHeight, files.first,
imageQuality, options.maxWidth,
options.maxHeight,
options.imageQuality,
);
}
/// Returns a [List<XFile>] with the images that were picked, if any.
@override
Future<List<XFile>> getMultiImageWithOptions({
MultiImagePickerOptions options = const MultiImagePickerOptions(),
}) async {
final List<XFile> images = await getFiles(
accept: _kAcceptImageMimeType,
multiple: true,
); );
final Iterable<Future<XFile>> resized = images.map(
(XFile image) => _imageResizer.resizeImageIfNeeded(
image,
options.imageOptions.maxWidth,
options.imageOptions.maxHeight,
options.imageOptions.imageQuality,
),
);
return Future.wait<XFile>(resized);
} }
/// Returns an [XFile] containing the video that was picked. /// Returns an [XFile] containing the video that was picked.
@ -153,7 +98,7 @@ class ImagePickerPlugin extends ImagePickerPlatform {
/// ///
/// If no images were picked, the return value is null. /// If no images were picked, the return value is null.
@override @override
Future<XFile> getVideo({ Future<XFile?> getVideo({
required ImageSource source, required ImageSource source,
CameraDevice preferredCameraDevice = CameraDevice.rear, CameraDevice preferredCameraDevice = CameraDevice.rear,
Duration? maxDuration, Duration? maxDuration,
@ -164,30 +109,7 @@ class ImagePickerPlugin extends ImagePickerPlatform {
accept: _kAcceptVideoMimeType, accept: _kAcceptVideoMimeType,
capture: capture, capture: capture,
); );
return files.first; return files.isEmpty ? null : files.first;
}
/// Injects a file input, and returns a list of XFile images that the user selected locally.
@override
Future<List<XFile>> getMultiImage({
double? maxWidth,
double? maxHeight,
int? imageQuality,
}) async {
final List<XFile> images = await getFiles(
accept: _kAcceptImageMimeType,
multiple: true,
);
final Iterable<Future<XFile>> resized = images.map(
(XFile image) => _imageResizer.resizeImageIfNeeded(
image,
maxWidth,
maxHeight,
imageQuality,
),
);
return Future.wait<XFile>(resized);
} }
/// Injects a file input, and returns a list of XFile media that the user selected locally. /// Injects a file input, and returns a list of XFile media that the user selected locally.
@ -239,6 +161,58 @@ class ImagePickerPlugin extends ImagePickerPlatform {
return _getSelectedXFiles(input); return _getSelectedXFiles(input);
} }
// Deprecated methods follow...
/// Returns an [XFile] with the image that was picked.
///
/// The `source` argument controls where the image comes from. This can
/// be either [ImageSource.camera] or [ImageSource.gallery].
///
/// Note that the `maxWidth`, `maxHeight` and `imageQuality` arguments are not supported on the web. If any of these arguments is supplied, it'll be silently ignored by the web version of the plugin.
///
/// Use `preferredCameraDevice` to specify the camera to use when the `source` is [ImageSource.camera].
/// The `preferredCameraDevice` is ignored when `source` is [ImageSource.gallery]. It is also ignored if the chosen camera is not supported on the device.
/// Defaults to [CameraDevice.rear].
///
/// If no images were picked, the return value is null.
@override
@Deprecated('Use getImageFromSource instead.')
Future<XFile?> getImage({
required ImageSource source,
double? maxWidth,
double? maxHeight,
int? imageQuality,
CameraDevice preferredCameraDevice = CameraDevice.rear,
}) async {
return getImageFromSource(
source: source,
options: ImagePickerOptions(
maxWidth: maxWidth,
maxHeight: maxHeight,
imageQuality: imageQuality,
preferredCameraDevice: preferredCameraDevice,
));
}
/// Injects a file input, and returns a list of XFile images that the user selected locally.
@override
@Deprecated('Use getMultiImageWithOptions instead.')
Future<List<XFile>> getMultiImage({
double? maxWidth,
double? maxHeight,
int? imageQuality,
}) async {
return getMultiImageWithOptions(
options: MultiImagePickerOptions(
imageOptions: ImageOptions(
maxWidth: maxWidth,
maxHeight: maxHeight,
imageQuality: imageQuality,
),
),
);
}
// DOM methods // DOM methods
/// Converts plugin configuration into a proper value for the `capture` attribute. /// Converts plugin configuration into a proper value for the `capture` attribute.
@ -267,29 +241,6 @@ class ImagePickerPlugin extends ImagePickerPlatform {
return input == null ? null : _getFilesFromInput(input); return input == null ? null : _getFilesFromInput(input);
} }
/// Monitors an <input type="file"> and returns the selected file.
Future<PickedFile> _getSelectedFile(html.FileUploadInputElement input) {
final Completer<PickedFile> completer = Completer<PickedFile>();
// Observe the input until we can return something
input.onChange.first.then((html.Event event) {
final List<html.File>? files = _handleOnChangeEvent(event);
if (!completer.isCompleted && files != null) {
completer.complete(PickedFile(
html.Url.createObjectUrl(files.first),
));
}
});
input.onError.first.then((html.Event event) {
if (!completer.isCompleted) {
completer.completeError(event);
}
});
// Note that we don't bother detaching from these streams, since the
// "input" gets re-created in the DOM every time the user needs to
// pick a file.
return completer.future;
}
/// Monitors an <input type="file"> and returns the selected file(s). /// Monitors an <input type="file"> and returns the selected file(s).
Future<List<XFile>> _getSelectedXFiles(html.FileUploadInputElement input) { Future<List<XFile>> _getSelectedXFiles(html.FileUploadInputElement input) {
final Completer<List<XFile>> completer = Completer<List<XFile>>(); final Completer<List<XFile>> completer = Completer<List<XFile>>();
@ -310,6 +261,11 @@ class ImagePickerPlugin extends ImagePickerPlatform {
}).toList()); }).toList());
} }
}); });
input.addEventListener('cancel', (html.Event _) {
completer.complete(<XFile>[]);
});
input.onError.first.then((html.Event event) { input.onError.first.then((html.Event event) {
if (!completer.isCompleted) { if (!completer.isCompleted) {
completer.completeError(event); completer.completeError(event);
@ -361,6 +317,7 @@ class ImagePickerPlugin extends ImagePickerPlatform {
void _injectAndActivate(html.Element element) { void _injectAndActivate(html.Element element) {
_target.children.clear(); _target.children.clear();
_target.children.add(element); _target.children.add(element);
// TODO(dit): Reimplement this with the showPicker() API, https://github.com/flutter/flutter/issues/130365
element.click(); element.click();
} }
} }

View File

@ -2,7 +2,7 @@ name: image_picker_for_web
description: Web platform implementation of image_picker description: Web platform implementation of image_picker
repository: https://github.com/flutter/packages/tree/main/packages/image_picker/image_picker_for_web repository: https://github.com/flutter/packages/tree/main/packages/image_picker/image_picker_for_web
issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+image_picker%22 issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+image_picker%22
version: 2.2.0 version: 3.0.0
environment: environment:
sdk: ">=2.18.0 <4.0.0" sdk: ">=2.18.0 <4.0.0"
@ -21,7 +21,7 @@ dependencies:
sdk: flutter sdk: flutter
flutter_web_plugins: flutter_web_plugins:
sdk: flutter sdk: flutter
image_picker_platform_interface: ^2.8.0 image_picker_platform_interface: ^2.9.0
mime: ^1.0.4 mime: ^1.0.4
dev_dependencies: dev_dependencies: