diff --git a/android/app/build.gradle b/android/app/build.gradle index b4181680..6b6b83a7 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -32,7 +32,7 @@ if (keystorePropertiesFile.exists()) { } android { - compileSdkVersion 30 + compileSdkVersion 31 sourceSets { main.java.srcDirs += 'src/main/kotlin' @@ -49,7 +49,7 @@ android { defaultConfig { applicationId "inventree.inventree_app" minSdkVersion 25 - targetSdkVersion 30 + targetSdkVersion 31 versionCode flutterVersionCode.toInteger() versionName flutterVersionName testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" diff --git a/assets/release_notes.md b/assets/release_notes.md index e18a63c9..405491a9 100644 --- a/assets/release_notes.md +++ b/assets/release_notes.md @@ -1,6 +1,11 @@ ## InvenTree App Release Notes --- +### x.x.x - March 2022 +--- + +- Enables printing of stock item labels + ### 0.5.6 - January 2022 --- diff --git a/lib/api.dart b/lib/api.dart index 5343937e..34e4a1c4 100644 --- a/lib/api.dart +++ b/lib/api.dart @@ -17,6 +17,7 @@ import "package:flutter_cache_manager/flutter_cache_manager.dart"; import "package:inventree/widget/dialogs.dart"; import "package:inventree/l10.dart"; import "package:inventree/inventree/sentry.dart"; +import "package:inventree/inventree/model.dart"; import "package:inventree/user_profile.dart"; import "package:inventree/widget/snacks.dart"; import "package:path_provider/path_provider.dart"; @@ -227,6 +228,39 @@ class InvenTreeAPI { int get apiVersion => _apiVersion; + // Are plugins enabled on the server? + bool _pluginsEnabled = false; + + // True plugin support requires API v34 or newer + // Returns True only if the server API version is new enough, and plugins are enabled + bool pluginsEnabled() => apiVersion >= 34 && _pluginsEnabled; + + // Cached list of plugins (refreshed when we connect to the server) + List _plugins = []; + + // Return a list of plugins enabled on the server + // Can optionally filter by a particular 'mixin' type + List getPlugins({String mixin = ""}) { + List plugins = []; + + for (var plugin in _plugins) { + // Do we wish to filter by a particular mixin? + if (mixin.isNotEmpty) { + if (!plugin.supportsMixin(mixin)) { + continue; + } + } + + plugins.add(plugin); + } + + // Return list of matching plugins + return plugins; + } + + // Test if the provided plugin mixin is supported by any active plugins + bool supportsMixin(String mixin) => getPlugins(mixin: mixin).isNotEmpty; + // Getter for server version information String get version => _version; @@ -294,6 +328,9 @@ class InvenTreeAPI { _BASE_URL = address; + // Clear the list of available plugins + _plugins.clear(); + print("Connecting to ${apiUrl} -> username=${username}"); APIResponse response; @@ -324,6 +361,7 @@ class InvenTreeAPI { // Default API version is 1 if not provided _apiVersion = (data["apiVersion"] ?? 1) as int; + _pluginsEnabled = (data["plugins_enabled"] ?? false) as bool; if (_apiVersion < _minApiVersion) { @@ -338,7 +376,7 @@ class InvenTreeAPI { showServerError( L10().serverOld, - message + message, ); return false; @@ -387,8 +425,11 @@ class InvenTreeAPI { _token = (data["token"] ?? "") as String; print("Received token - $_token"); - // Request user role information - await getUserRoles(); + // Request user role information (async) + getUserRoles(); + + // Request plugin information (async) + getPluginInformation(); // Ok, probably pretty good... return true; @@ -450,7 +491,7 @@ class InvenTreeAPI { // Any "older" version of the server allows any API method for any logged in user! // We will return immediately, but request the user roles in the background - var response = await get(_URL_GET_ROLES, expectedStatusCode: 200); + final response = await get(_URL_GET_ROLES, expectedStatusCode: 200); if (!response.successful()) { return; @@ -460,7 +501,31 @@ class InvenTreeAPI { if (data.containsKey("roles")) { // Save a local copy of the user roles - roles = response.data["roles"] as Map; + roles = (response.data["roles"] ?? {}) as Map; + } + } + + // Request plugin information from the server + Future getPluginInformation() async { + + // The server does not support plugins, or they are not enabled + if (!pluginsEnabled()) { + _plugins.clear(); + return; + } + + print("Requesting plugin information"); + + // Request a list of plugins from the server + final List results = await InvenTreePlugin().list(); + + for (var result in results) { + if (result is InvenTreePlugin) { + if (result.active) { + // Only add plugins that are active + _plugins.add(result); + } + } } } diff --git a/lib/api_form.dart b/lib/api_form.dart index 75a6a089..5b28f5e4 100644 --- a/lib/api_form.dart +++ b/lib/api_form.dart @@ -778,8 +778,6 @@ Map extractFieldDefinition(Map data, String lo String el = path.last; if (!_data.containsKey(el)) { - print("Could not find field definition for ${lookup}"); - print("- Final field path ${el} missing from data"); return {}; } else { @@ -824,24 +822,28 @@ Future launchApiForm( IconData icon = FontAwesomeIcons.save, }) async { - var options = await InvenTreeAPI().options(url); - - // Invalid response from server - if (!options.isValid()) { - return; - } - // List of fields defined by the server - Map serverFields = extractFields(options); + Map serverFields = {}; - if (serverFields.isEmpty) { - // User does not have permission to perform this action - showSnackIcon( - L10().response403, - icon: FontAwesomeIcons.userTimes, - ); + if (url.isNotEmpty) { + var options = await InvenTreeAPI().options(url); - return; + // Invalid response from server + if (!options.isValid()) { + return; + } + + serverFields = extractFields(options); + + if (serverFields.isEmpty) { + // User does not have permission to perform this action + showSnackIcon( + L10().response403, + icon: FontAwesomeIcons.userTimes, + ); + + return; + } } // Construct a list of APIFormField objects @@ -868,8 +870,7 @@ Future launchApiForm( // Skip fields with empty definitions if (field.definition.isEmpty) { - print("ERROR: Empty field definition for field '${fieldName}'"); - continue; + print("Warning: Empty field definition for field '${fieldName}'"); } // Add instance value to the field @@ -1170,6 +1171,23 @@ class _APIFormWidgetState extends State { } } + // Run custom onSuccess function + var successFunc = onSuccess; + + // An "empty" URL means we don't want to submit the form anywhere + // Perhaps we just want to process the data? + if (url.isEmpty) { + // Hide the form + Navigator.pop(context); + + if (successFunc != null) { + // Return the raw "submitted" data, rather than the server response + successFunc(data); + } + + return; + } + final response = await _submit(data); if (!response.isValid()) { @@ -1187,9 +1205,6 @@ class _APIFormWidgetState extends State { // TODO: Display a snackBar - // Run custom onSuccess function - var successFunc = onSuccess; - if (successFunc != null) { // Ensure the response is a valid JSON structure diff --git a/lib/inventree/model.dart b/lib/inventree/model.dart index f6f88f73..a9eba502 100644 --- a/lib/inventree/model.dart +++ b/lib/inventree/model.dart @@ -500,6 +500,41 @@ class InvenTreeModel { } +/* + * Class representing a single plugin instance + */ +class InvenTreePlugin extends InvenTreeModel { + + InvenTreePlugin() : super(); + + InvenTreePlugin.fromJson(Map json) : super.fromJson(json); + + @override + InvenTreeModel createFromJson(Map json) { + return InvenTreePlugin.fromJson(json); + } + + @override + String get URL => "plugin/"; + + String get key => (jsondata["key"] ?? "") as String; + + bool get active => (jsondata["active"] ?? false) as bool; + + // Return the metadata struct for this plugin + Map get _meta => (jsondata["meta"] ?? {}) as Map; + + String get humanName => (_meta["human_name"] ?? "") as String; + + // Return the mixins struct for this plugin + Map get _mixins => (jsondata["mixins"] ?? {}) as Map; + + bool supportsMixin(String mixin) { + return _mixins.containsKey(mixin); + } +} + + class InvenTreeAttachment extends InvenTreeModel { // Class representing an "attachment" file InvenTreeAttachment() : super(); diff --git a/lib/l10n b/lib/l10n index 2396ebd4..42662619 160000 --- a/lib/l10n +++ b/lib/l10n @@ -1 +1 @@ -Subproject commit 2396ebd447a616b5eebddd8d4ee407253d678d2f +Subproject commit 42662619b3d094e9bd41d3f715cac2beff7cf6f6 diff --git a/lib/widget/purchase_order_detail.dart b/lib/widget/purchase_order_detail.dart index 169a82b8..d6cdfea2 100644 --- a/lib/widget/purchase_order_detail.dart +++ b/lib/widget/purchase_order_detail.dart @@ -227,6 +227,8 @@ class _PurchaseOrderDetailState extends RefreshableState children = []; + // TODO: Add in this option once the SupplierPart detail view is implemented + /* children.add( SimpleDialogOption( onPressed: () { @@ -240,6 +242,7 @@ class _PurchaseOrderDetailState extends RefreshableState { // StockItem object final InvenTreeStockItem item; + // Is label printing enabled for this StockItem? + // This will be determined when the widget is loaded + List> labels = []; + // Part object InvenTreePart? part; @@ -103,8 +107,127 @@ class _StockItemDisplayState extends RefreshableState { // Request part information part = await InvenTreePart().get(item.partId) as InvenTreePart?; - // Request test results... - await item.getTestResults(); + // Request test results (async) + item.getTestResults().then((value) { + setState(() { + // Update + }); + }); + + // Request information on labels available for this stock item + if (InvenTreeAPI().pluginsEnabled()) { + _getLabels(); + } + } + + Future _getLabels() async { + // Clear the existing labels list + labels.clear(); + + // If the server does not support label printing, don't bother! + if (!InvenTreeAPI().supportsMixin("labels")) { + return; + } + + InvenTreeAPI().get( + "/label/stock/", + params: { + "enabled": "true", + "item": "${item.pk}", + }, + ).then((APIResponse response) { + if (response.isValid() && response.statusCode == 200) { + for (var label in response.data) { + if (label is Map) { + labels.add(label); + } + } + + setState(() { + }); + } + }); + } + + /// Opens a popup dialog allowing user to select a label for printing + Future _printLabel(BuildContext context) async { + + var plugins = InvenTreeAPI().getPlugins(mixin: "labels"); + + dynamic initial_label; + dynamic initial_plugin; + + List> label_options = []; + List> plugin_options = []; + + for (var label in labels) { + label_options.add({ + "display_name": label["description"], + "value": label["pk"], + }); + } + + for (var plugin in plugins) { + plugin_options.add({ + "display_name": plugin.humanName, + "value": plugin.key, + }); + } + + if (labels.length == 1) { + initial_label = labels.first["pk"]; + } + + if (plugins.length == 1) { + initial_plugin = plugins.first.key; + } + + Map fields = { + "label": { + "label": "Label Template", + "type": "choice", + "value": initial_label, + "choices": label_options, + "required": true, + }, + "plugin": { + "label": "Printer", + "type": "choice", + "value": initial_plugin, + "choices": plugin_options, + "required": true, + } + }; + + launchApiForm( + context, + L10().printLabel, + "", + fields, + icon: FontAwesomeIcons.print, + onSuccess: (Map data) async { + int labelId = (data["label"] ?? -1) as int; + String pluginKey = (data["plugin"] ?? "") as String; + + if (labelId != -1 && pluginKey.isNotEmpty) { + String url = "/label/stock/${labelId}/print/?item=${item.pk}&plugin=${pluginKey}"; + + InvenTreeAPI().get(url).then((APIResponse response) { + if (response.isValid() && response.statusCode == 200) { + showSnackIcon( + L10().printLabelSuccess, + success: true + ); + } else { + showSnackIcon( + L10().printLabelFailure, + success: false, + ); + } + }); + } + }, + ); } Future _editStockItem(BuildContext context) async { @@ -859,6 +982,19 @@ class _StockItemDisplayState extends RefreshableState { ); } + // Print label (if label printing plugins exist) + if (labels.isNotEmpty) { + tiles.add( + ListTile( + title: Text(L10().printLabel), + leading: FaIcon(FontAwesomeIcons.print, color: COLOR_CLICK), + onTap: () { + _printLabel(context); + }, + ), + ); + } + return tiles; } diff --git a/pubspec.lock b/pubspec.lock index 6a199be7..677e1f81 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -7,21 +7,21 @@ packages: name: archive url: "https://pub.dartlang.org" source: hosted - version: "3.1.2" + version: "3.2.2" args: dependency: transitive description: name: args url: "https://pub.dartlang.org" source: hosted - version: "2.0.0" + version: "2.3.0" async: dependency: transitive description: name: async url: "https://pub.dartlang.org" source: hosted - version: "2.6.1" + version: "2.8.2" audioplayers: dependency: "direct main" description: @@ -35,7 +35,7 @@ packages: name: back_button_interceptor url: "https://pub.dartlang.org" source: hosted - version: "5.0.1" + version: "5.0.2" boolean_selector: dependency: transitive description: @@ -49,7 +49,7 @@ packages: name: cached_network_image url: "https://pub.dartlang.org" source: hosted - version: "3.1.0" + version: "3.2.0" cached_network_image_platform_interface: dependency: transitive description: @@ -70,28 +70,35 @@ packages: name: camera url: "https://pub.dartlang.org" source: hosted - version: "0.8.1+3" + version: "0.9.4+16" camera_platform_interface: dependency: transitive description: name: camera_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "2.0.1" + version: "2.1.5" + camera_web: + dependency: transitive + description: + name: camera_web + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.1+3" characters: dependency: transitive description: name: characters url: "https://pub.dartlang.org" source: hosted - version: "1.1.0" + version: "1.2.0" charcode: dependency: transitive description: name: charcode url: "https://pub.dartlang.org" source: hosted - version: "1.2.0" + version: "1.3.1" clock: dependency: transitive description: @@ -112,7 +119,7 @@ packages: name: cross_file url: "https://pub.dartlang.org" source: hosted - version: "0.3.1+1" + version: "0.3.2" crypto: dependency: transitive description: @@ -126,7 +133,7 @@ packages: name: cupertino_icons url: "https://pub.dartlang.org" source: hosted - version: "1.0.3" + version: "1.0.4" datetime_picker_formfield: dependency: "direct main" description: @@ -140,28 +147,28 @@ packages: name: device_info_plus url: "https://pub.dartlang.org" source: hosted - version: "2.1.0" + version: "2.2.0" device_info_plus_linux: dependency: transitive description: name: device_info_plus_linux url: "https://pub.dartlang.org" source: hosted - version: "2.1.0" + version: "2.1.1" device_info_plus_macos: dependency: transitive description: name: device_info_plus_macos url: "https://pub.dartlang.org" source: hosted - version: "2.1.0" + version: "2.2.2" device_info_plus_platform_interface: dependency: transitive description: name: device_info_plus_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "2.1.0" + version: "2.3.0+1" device_info_plus_web: dependency: transitive description: @@ -175,7 +182,7 @@ packages: name: device_info_plus_windows url: "https://pub.dartlang.org" source: hosted - version: "2.1.0" + version: "2.1.1" dropdown_search: dependency: "direct main" description: @@ -210,7 +217,7 @@ packages: name: file_picker url: "https://pub.dartlang.org" source: hosted - version: "4.0.0" + version: "4.5.1" flutter: dependency: "direct main" description: flutter @@ -222,21 +229,21 @@ packages: name: flutter_blurhash url: "https://pub.dartlang.org" source: hosted - version: "0.6.0" + version: "0.6.4" flutter_cache_manager: dependency: transitive description: name: flutter_cache_manager url: "https://pub.dartlang.org" source: hosted - version: "3.1.2" + version: "3.3.0" flutter_launcher_icons: dependency: "direct dev" description: name: flutter_launcher_icons url: "https://pub.dartlang.org" source: hosted - version: "0.9.0" + version: "0.9.2" flutter_localizations: dependency: "direct main" description: flutter @@ -248,14 +255,14 @@ packages: name: flutter_markdown url: "https://pub.dartlang.org" source: hosted - version: "0.6.2" + version: "0.6.9" flutter_plugin_android_lifecycle: dependency: transitive description: name: flutter_plugin_android_lifecycle url: "https://pub.dartlang.org" source: hosted - version: "2.0.2" + version: "2.0.5" flutter_test: dependency: "direct dev" description: flutter @@ -272,14 +279,14 @@ packages: name: font_awesome_flutter url: "https://pub.dartlang.org" source: hosted - version: "9.1.0" + version: "9.2.0" http: dependency: "direct main" description: name: http url: "https://pub.dartlang.org" source: hosted - version: "0.13.3" + version: "0.13.4" http_parser: dependency: transitive description: @@ -293,28 +300,28 @@ packages: name: image url: "https://pub.dartlang.org" source: hosted - version: "3.0.2" + version: "3.1.3" image_picker: dependency: "direct main" description: name: image_picker url: "https://pub.dartlang.org" source: hosted - version: "0.8.3+2" + version: "0.8.4+11" image_picker_for_web: dependency: transitive description: name: image_picker_for_web url: "https://pub.dartlang.org" source: hosted - version: "2.1.2" + version: "2.1.6" image_picker_platform_interface: dependency: transitive description: name: image_picker_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "2.2.0" + version: "2.4.4" infinite_scroll_pagination: dependency: "direct main" description: @@ -342,42 +349,49 @@ packages: name: lint url: "https://pub.dartlang.org" source: hosted - version: "1.6.0" + version: "1.8.2" markdown: dependency: transitive description: name: markdown url: "https://pub.dartlang.org" source: hosted - version: "4.0.0" + version: "4.0.1" matcher: dependency: transitive description: name: matcher url: "https://pub.dartlang.org" source: hosted - version: "0.12.10" + version: "0.12.11" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.3" meta: dependency: transitive description: name: meta url: "https://pub.dartlang.org" source: hosted - version: "1.3.0" + version: "1.7.0" octo_image: dependency: transitive description: name: octo_image url: "https://pub.dartlang.org" source: hosted - version: "1.0.0+1" + version: "1.0.1" one_context: dependency: "direct main" description: name: one_context url: "https://pub.dartlang.org" source: hosted - version: "1.1.0" + version: "1.1.1" open_file: dependency: "direct main" description: @@ -391,7 +405,7 @@ packages: name: package_info_plus url: "https://pub.dartlang.org" source: hosted - version: "1.0.4" + version: "1.4.0" package_info_plus_linux: dependency: transitive description: @@ -405,7 +419,7 @@ packages: name: package_info_plus_macos url: "https://pub.dartlang.org" source: hosted - version: "1.1.1" + version: "1.3.0" package_info_plus_platform_interface: dependency: transitive description: @@ -419,14 +433,14 @@ packages: name: package_info_plus_web url: "https://pub.dartlang.org" source: hosted - version: "1.0.3" + version: "1.0.4" package_info_plus_windows: dependency: transitive description: name: package_info_plus_windows url: "https://pub.dartlang.org" source: hosted - version: "1.0.3" + version: "1.0.4" path: dependency: "direct main" description: @@ -447,28 +461,28 @@ packages: name: path_provider_linux url: "https://pub.dartlang.org" source: hosted - version: "2.0.0" + version: "2.1.5" path_provider_macos: dependency: transitive description: name: path_provider_macos url: "https://pub.dartlang.org" source: hosted - version: "2.0.0" + version: "2.0.5" path_provider_platform_interface: dependency: transitive description: name: path_provider_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "2.0.1" + version: "2.0.3" path_provider_windows: dependency: transitive description: name: path_provider_windows url: "https://pub.dartlang.org" source: hosted - version: "2.0.1" + version: "2.0.5" pedantic: dependency: transitive description: @@ -482,28 +496,28 @@ packages: name: petitparser url: "https://pub.dartlang.org" source: hosted - version: "4.1.0" + version: "4.4.0" platform: dependency: transitive description: name: platform url: "https://pub.dartlang.org" source: hosted - version: "3.0.0" + version: "3.1.0" plugin_platform_interface: dependency: transitive description: name: plugin_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "2.0.0" + version: "2.1.2" process: dependency: transitive description: name: process url: "https://pub.dartlang.org" source: hosted - version: "4.2.1" + version: "4.2.4" qr_code_scanner: dependency: "direct main" description: @@ -517,21 +531,21 @@ packages: name: quiver url: "https://pub.dartlang.org" source: hosted - version: "3.0.1" + version: "3.0.1+1" rxdart: dependency: transitive description: name: rxdart url: "https://pub.dartlang.org" source: hosted - version: "0.27.1" + version: "0.27.3" sembast: dependency: "direct main" description: name: sembast url: "https://pub.dartlang.org" source: hosted - version: "3.1.0+2" + version: "3.2.0" sentry: dependency: transitive description: @@ -552,21 +566,35 @@ packages: name: shared_preferences url: "https://pub.dartlang.org" source: hosted - version: "2.0.6" + version: "2.0.13" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.11" + shared_preferences_ios: + dependency: transitive + description: + name: shared_preferences_ios + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" shared_preferences_linux: dependency: transitive description: name: shared_preferences_linux url: "https://pub.dartlang.org" source: hosted - version: "2.0.0" + version: "2.1.0" shared_preferences_macos: dependency: transitive description: name: shared_preferences_macos url: "https://pub.dartlang.org" source: hosted - version: "2.0.0" + version: "2.0.3" shared_preferences_platform_interface: dependency: transitive description: @@ -580,14 +608,14 @@ packages: name: shared_preferences_web url: "https://pub.dartlang.org" source: hosted - version: "2.0.0" + version: "2.0.3" shared_preferences_windows: dependency: transitive description: name: shared_preferences_windows url: "https://pub.dartlang.org" source: hosted - version: "2.0.0" + version: "2.1.0" sky_engine: dependency: transitive description: flutter @@ -613,14 +641,14 @@ packages: name: sqflite url: "https://pub.dartlang.org" source: hosted - version: "2.0.0+3" + version: "2.0.2" sqflite_common: dependency: transitive description: name: sqflite_common url: "https://pub.dartlang.org" source: hosted - version: "2.0.0+2" + version: "2.2.1" stack_trace: dependency: transitive description: @@ -669,7 +697,7 @@ packages: name: test_api url: "https://pub.dartlang.org" source: hosted - version: "0.3.0" + version: "0.4.8" typed_data: dependency: transitive description: @@ -690,70 +718,70 @@ packages: name: url_launcher_linux url: "https://pub.dartlang.org" source: hosted - version: "2.0.0" + version: "2.0.3" url_launcher_macos: dependency: transitive description: name: url_launcher_macos url: "https://pub.dartlang.org" source: hosted - version: "2.0.0" + version: "2.0.3" url_launcher_platform_interface: dependency: transitive description: name: url_launcher_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "2.0.3" + version: "2.0.5" url_launcher_web: dependency: transitive description: name: url_launcher_web url: "https://pub.dartlang.org" source: hosted - version: "2.0.1" + version: "2.0.9" url_launcher_windows: dependency: transitive description: name: url_launcher_windows url: "https://pub.dartlang.org" source: hosted - version: "2.0.0" + version: "2.0.2" uuid: dependency: transitive description: name: uuid url: "https://pub.dartlang.org" source: hosted - version: "3.0.4" + version: "3.0.6" vector_math: dependency: transitive description: name: vector_math url: "https://pub.dartlang.org" source: hosted - version: "2.1.0" + version: "2.1.1" win32: dependency: transitive description: name: win32 url: "https://pub.dartlang.org" source: hosted - version: "2.1.5" + version: "2.4.2" xdg_directories: dependency: transitive description: name: xdg_directories url: "https://pub.dartlang.org" source: hosted - version: "0.2.0" + version: "0.2.0+1" xml: dependency: transitive description: name: xml url: "https://pub.dartlang.org" source: hosted - version: "5.1.2" + version: "5.3.1" yaml: dependency: transitive description: @@ -762,5 +790,5 @@ packages: source: hosted version: "3.1.0" sdks: - dart: ">=2.13.0 <3.0.0" - flutter: ">=2.0.0" + dart: ">=2.16.0 <3.0.0" + flutter: ">=2.10.0"