diff --git a/.gitignore b/.gitignore index 3820a95..f0c4dec 100644 --- a/.gitignore +++ b/.gitignore @@ -1,16 +1,23 @@ +# Do not remove or rename entries in this file, only add new ones +# See https://github.com/flutter/flutter/issues/128635 for more context. + # Miscellaneous *.class +*.lock *.log *.pyc *.swp .DS_Store .atom/ -.build/ .buildlog/ .history .svn/ -.swiftpm/ -migrate_working_dir/ +lib/l10n/app_localizations* + +# As packages are no longer pinned, we use a lockfile for testing locally. +# When unpinning packages, Using lockfiles ensures that failures in PRs are +# actually due to those PRs, not due to a package being updated. +!/pubspec.lock # IntelliJ related *.iml @@ -18,28 +25,142 @@ migrate_working_dir/ *.iws .idea/ -# The .vscode folder contains launch configuration and tasks you configure in -# VS Code which you may wish to be included in version control, so this line -# is commented out by default. -#.vscode/ +# Visual Studio Code related +.classpath +.project +.settings/ +.vscode/* +.ccls-cache + +# This file, on the master branch, should never exist or be checked-in. +# +# On a *final* release branch, that is, what will ship to stable or beta, the +# file can be force added (git add --force) and checked-in in order to effectively +# "pin" the engine artifact version so the flutter tool does not need to use git +# to determine the engine artifacts. +# +# See https://github.com/flutter/flutter/blob/main/docs/tool/Engine-artifacts.md. +/bin/internal/engine.version + +# Flutter repo-specific +/bin/cache/ +/bin/internal/bootstrap.bat +/bin/internal/bootstrap.sh +/bin/internal/engine.realm +/bin/mingit/ +/dev/benchmarks/mega_gallery/ +/dev/bots/.recipe_deps +/dev/bots/android_tools/ +/dev/devicelab/ABresults*.json +/dev/docs/doc/ +/dev/docs/api_docs.zip +/dev/docs/flutter.docs.zip +/dev/docs/lib/ +/dev/docs/pubspec.yaml +/dev/integration_tests/**/xcuserdata +/dev/integration_tests/**/Pods +/packages/flutter/coverage/ +version +analysis_benchmark.json + +# packages file containing multi-root paths +.packages.generated # Flutter/Dart/Pub related **/doc/api/ -**/ios/Flutter/.last_build_id .dart_tool/ .flutter-plugins-dependencies +**/generated_plugin_registrant.dart +.packages +.pub-preload-cache/ .pub-cache/ .pub/ -/build/ -/coverage/ +build/ +flutter_*.png +linked_*.ds +unlinked.ds +unlinked_spec.ds -# Symbolication related +# Android related +**/android/**/gradle-wrapper.jar +.gradle/ +**/android/captures/ +**/android/gradlew +**/android/gradlew.bat +**/android/**/GeneratedPluginRegistrant.java +**/android/key.properties +*.jks +local.properties +**/.cxx/ + +# iOS/XCode related +**/ios/**/*.mode1v3 +**/ios/**/*.mode2v3 +**/ios/**/*.moved-aside +**/ios/**/*.pbxuser +**/ios/**/*.perspectivev3 +**/ios/**/*sync/ +**/ios/**/.sconsign.dblite +**/ios/**/.tags* +**/ios/**/.vagrant/ +**/ios/**/DerivedData/ +**/ios/**/Icon? +**/ios/**/Pods/ +**/ios/**/.symlinks/ +**/ios/**/profile +**/ios/**/xcuserdata +**/ios/.generated/ +**/ios/Flutter/.last_build_id +**/ios/Flutter/App.framework +**/ios/Flutter/Flutter.framework +**/ios/Flutter/Flutter.podspec +**/ios/Flutter/Generated.xcconfig +**/ios/Flutter/ephemeral +**/ios/Flutter/app.flx +**/ios/Flutter/app.zip +**/ios/Flutter/flutter_assets/ +**/ios/Flutter/flutter_export_environment.sh +**/ios/ServiceDefinitions.json +**/ios/Runner/GeneratedPluginRegistrant.* + +# macOS +**/Flutter/ephemeral/ +**/Pods/ +**/macos/Flutter/GeneratedPluginRegistrant.swift +**/macos/Flutter/ephemeral +**/xcuserdata/ + +# Windows +**/windows/flutter/ephemeral/ +**/windows/flutter/generated_plugin_registrant.cc +**/windows/flutter/generated_plugin_registrant.h +**/windows/flutter/generated_plugins.cmake + +# Linux +**/linux/flutter/ephemeral/ +**/linux/flutter/generated_plugin_registrant.cc +**/linux/flutter/generated_plugin_registrant.h +**/linux/flutter/generated_plugins.cmake + +# Coverage +coverage/ + +# Symbols app.*.symbols -# Obfuscation related -app.*.map.json +# Exceptions to above rules. +!**/ios/**/default.mode1v3 +!**/ios/**/default.mode2v3 +!**/ios/**/default.pbxuser +!**/ios/**/default.perspectivev3 +!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages +!/dev/ci/**/Gemfile.lock +!.vscode/settings.json -# Android Studio will place build artifacts here -/android/app/debug -/android/app/profile -/android/app/release +# Monorepo +.cipd +.gclient +.gclient_entries +.python-version +.gclient_previous_custom_vars +.gclient_previous_sync_commits \ No newline at end of file diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index a26e2fe..6c04936 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,5 +1,6 @@ + (context, AppLocalizations); - } - - static const LocalizationsDelegate delegate = - _AppLocalizationsDelegate(); - - /// A list of this localizations delegate along with the default localizations - /// delegates. - /// - /// Returns a list of localizations delegates containing this delegate along with - /// GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate, - /// and GlobalWidgetsLocalizations.delegate. - /// - /// Additional delegates can be added by appending to this list in - /// MaterialApp. This list does not have to be used at all if a custom list - /// of delegates is preferred or required. - static const List> localizationsDelegates = - >[ - delegate, - GlobalMaterialLocalizations.delegate, - GlobalCupertinoLocalizations.delegate, - GlobalWidgetsLocalizations.delegate, - ]; - - /// A list of this localizations delegate's supported locales. - static const List supportedLocales = [ - Locale('de'), - Locale('en'), - ]; - - /// No description provided for @addToCollection. - /// - /// In en, this message translates to: - /// **'Add to {collection_name}'** - String addToCollection(String collection_name); - - /// No description provided for @cancel. - /// - /// In en, this message translates to: - /// **'Cancel'** - String get cancel; - - /// No description provided for @chooseCollection. - /// - /// In en, this message translates to: - /// **'Choose Collection'** - String get chooseCollection; - - /// No description provided for @collections. - /// - /// In en, this message translates to: - /// **'Collections'** - String get collections; - - /// No description provided for @tipCreateCollections. - /// - /// In en, this message translates to: - /// **'Create your first Collection to get started!'** - String get tipCreateCollections; - - /// No description provided for @search. - /// - /// In en, this message translates to: - /// **'Search'** - String get search; - - /// No description provided for @createBookmark. - /// - /// In en, this message translates to: - /// **'Create Bookmark'** - String get createBookmark; - - /// No description provided for @createCollection. - /// - /// In en, this message translates to: - /// **'Create Collection'** - String get createCollection; - - /// No description provided for @create. - /// - /// In en, this message translates to: - /// **'Create'** - String get create; - - /// No description provided for @delete. - /// - /// In en, this message translates to: - /// **'Delete'** - String get delete; - - /// No description provided for @add. - /// - /// In en, this message translates to: - /// **'Add'** - String get add; - - /// No description provided for @startSearching. - /// - /// In en, this message translates to: - /// **'Start searching'** - String get startSearching; - - /// No description provided for @tipNoResults. - /// - /// In en, this message translates to: - /// **'There are no results that match your search'** - String get tipNoResults; - - /// No description provided for @collectionName. - /// - /// In en, this message translates to: - /// **'Collection Name'** - String get collectionName; - - /// No description provided for @bookmarkTitle. - /// - /// In en, this message translates to: - /// **'Bookmark Title'** - String get bookmarkTitle; - - /// No description provided for @url. - /// - /// In en, this message translates to: - /// **'Url'** - String get url; - - /// No description provided for @description. - /// - /// In en, this message translates to: - /// **'Description'** - String get description; - - /// No description provided for @settings. - /// - /// In en, this message translates to: - /// **'Settings'** - String get settings; - - /// No description provided for @activateJsonExport. - /// - /// In en, this message translates to: - /// **'Activate json export'** - String get activateJsonExport; -} - -class _AppLocalizationsDelegate - extends LocalizationsDelegate { - const _AppLocalizationsDelegate(); - - @override - Future load(Locale locale) { - return SynchronousFuture(lookupAppLocalizations(locale)); - } - - @override - bool isSupported(Locale locale) => - ['de', 'en'].contains(locale.languageCode); - - @override - bool shouldReload(_AppLocalizationsDelegate old) => false; -} - -AppLocalizations lookupAppLocalizations(Locale locale) { - // Lookup logic when only language code is specified. - switch (locale.languageCode) { - case 'de': - return AppLocalizationsDe(); - case 'en': - return AppLocalizationsEn(); - } - - throw FlutterError( - 'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely ' - 'an issue with the localizations generation tool. Please file an issue ' - 'on GitHub with a reproducible sample app and the gen-l10n configuration ' - 'that was used.', - ); -} diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart deleted file mode 100644 index 5979058..0000000 --- a/lib/l10n/app_localizations_de.dart +++ /dev/null @@ -1,69 +0,0 @@ -// ignore: unused_import -import 'package:intl/intl.dart' as intl; -import 'app_localizations.dart'; - -// ignore_for_file: type=lint - -/// The translations for German (`de`). -class AppLocalizationsDe extends AppLocalizations { - AppLocalizationsDe([String locale = 'de']) : super(locale); - - @override - String addToCollection(String collection_name) { - return 'Speichern in $collection_name'; - } - - @override - String get cancel => 'Abbrechen'; - - @override - String get chooseCollection => 'Sammlung auswählen'; - - @override - String get collections => 'Sammlungen'; - - @override - String get tipCreateCollections => 'Erstelle deine erste Sammlung!'; - - @override - String get search => 'Suche'; - - @override - String get createBookmark => 'Lesezeichen erstellen'; - - @override - String get createCollection => 'Sammlung erstellen'; - - @override - String get create => 'Erstellen'; - - @override - String get delete => 'Löschen'; - - @override - String get add => 'Hinzufügen'; - - @override - String get startSearching => 'Suche etwas'; - - @override - String get tipNoResults => 'Keine Suchergebnisse gefunden'; - - @override - String get collectionName => 'Name der Sammlung'; - - @override - String get bookmarkTitle => 'Titel des Lesezeichens'; - - @override - String get url => 'Url'; - - @override - String get description => 'Beschreibung'; - - @override - String get settings => 'Einstellungen'; - - @override - String get activateJsonExport => 'Json-Export aktivieren'; -} diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart deleted file mode 100644 index 1ad6476..0000000 --- a/lib/l10n/app_localizations_en.dart +++ /dev/null @@ -1,70 +0,0 @@ -// ignore: unused_import -import 'package:intl/intl.dart' as intl; -import 'app_localizations.dart'; - -// ignore_for_file: type=lint - -/// The translations for English (`en`). -class AppLocalizationsEn extends AppLocalizations { - AppLocalizationsEn([String locale = 'en']) : super(locale); - - @override - String addToCollection(String collection_name) { - return 'Add to $collection_name'; - } - - @override - String get cancel => 'Cancel'; - - @override - String get chooseCollection => 'Choose Collection'; - - @override - String get collections => 'Collections'; - - @override - String get tipCreateCollections => - 'Create your first Collection to get started!'; - - @override - String get search => 'Search'; - - @override - String get createBookmark => 'Create Bookmark'; - - @override - String get createCollection => 'Create Collection'; - - @override - String get create => 'Create'; - - @override - String get delete => 'Delete'; - - @override - String get add => 'Add'; - - @override - String get startSearching => 'Start searching'; - - @override - String get tipNoResults => 'There are no results that match your search'; - - @override - String get collectionName => 'Collection Name'; - - @override - String get bookmarkTitle => 'Bookmark Title'; - - @override - String get url => 'Url'; - - @override - String get description => 'Description'; - - @override - String get settings => 'Settings'; - - @override - String get activateJsonExport => 'Activate json export'; -} diff --git a/lib/pages/settings_page.dart b/lib/pages/settings_page.dart index 98cfc8d..babf159 100644 --- a/lib/pages/settings_page.dart +++ b/lib/pages/settings_page.dart @@ -1,15 +1,86 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; +import 'package:permission_handler/permission_handler.dart'; import '../l10n/app_localizations.dart'; +import '../service/notifying.dart'; +import '../service/permission_service.dart'; +import '../service/storage.dart'; -class SettingsPage extends StatelessWidget { +class SettingsPage extends StatefulWidget { static const routeName = '/settings'; const SettingsPage({super.key}); + @override + State createState() => _SettingsPageState(); +} + +class _SettingsPageState extends State { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text(AppLocalizations.of(context)!.settings)), + body: SizedBox( + width: MediaQuery.of(context).size.width * 0.9, + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + AppLocalizations.of(context)!.appData, + style: Theme.of(context).textTheme.titleLarge, + ), + ElevatedButton( + onPressed: () => onActivateJsonImportPressed(), + child: Text(AppLocalizations.of(context)!.import), + ), + ElevatedButton( + onPressed: () => onActivateJsonExportPressed(), + child: Text(AppLocalizations.of(context)!.export), + ), + ], + ), + ), ); } + + void onActivateJsonExportPressed() async { + if (!await checkStoragePermission) return; + + Storage.exportToJsonFile().then(showExportInfo); + } + + void onActivateJsonImportPressed() async { + if (!await checkStoragePermission) return; + Storage.importFromJsonFile().then(showImportInfo); + } + + Future get checkStoragePermission async { + if (!(await PermissionService.requestStoragePermission).isGranted) { + if (mounted) { + Notifying.showErrorSnackbar( + context, + AppLocalizations.of(context)!.errorStoragePermisson, + ); + return false; + } + } + return true; + } + + void showExportInfo(bool success) => Notifying.showSnackbar( + context, + text: success + ? AppLocalizations.of(context)!.exportSuccess + : AppLocalizations.of(context)!.exportFailed, + isError: success, + ); + + void showImportInfo(bool success) => Notifying.showSnackbar( + context, + text: success + ? AppLocalizations.of(context)!.importSuccess + : AppLocalizations.of(context)!.importFailed, + isError: success, + ); } diff --git a/lib/service/json_file_service.dart b/lib/service/json_file_service.dart new file mode 100644 index 0000000..95d1162 --- /dev/null +++ b/lib/service/json_file_service.dart @@ -0,0 +1,77 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:file_selector/file_selector.dart'; +import 'package:flutter/services.dart'; + +import '../model/bookmark.dart'; +import '../model/collection.dart'; +import '../assets/constants.dart' as constants; + +class JsonFileService { + static Future exportToJson({ + required List collections, + required List bookmarks, + }) async { + try { + final dir = await _directoryPath; + if (dir.isEmpty) return false; + + final data = { + 'collections': collections.map((c) => c.toJson()).toList(), + 'bookmarks': bookmarks.map((b) => b.toJson()).toList(), + }; + final json = jsonEncode(data).codeUnits; + final file = XFile.fromData( + Uint8List.fromList(json), + mimeType: 'application/json', + name: constants.jsonFileName, + ); + + file.saveTo('$dir/${constants.jsonFileName}'); + } catch (e) { + return false; + } + return false; + } + + static Future<({List collections, List bookmarks})> + importFromJson() async { + try { + const typeGroup = XTypeGroup(label: 'json', extensions: ['json']); + final XFile? file = await openFile( + acceptedTypeGroups: [typeGroup], + ); + + if (file == null) { + return (collections: [], bookmarks: []); + } + + final jsonString = await file.readAsString(); + + final data = jsonDecode(jsonString) as Map; + + final collections = (data['collections'] as List? ?? []) + .map((json) => Collection.fromJson(json as Map)) + .toList(); + + final bookmarks = (data['bookmarks'] as List? ?? []) + .map((json) => Bookmark.fromJson(json as Map)) + .toList(); + + return (collections: collections, bookmarks: bookmarks); + } catch (e) { + return (collections: [], bookmarks: []); + } + } + + static Future get _directoryPath async { + if (Platform.isAndroid) { + return await getDirectoryPath( + initialDirectory: constants.defaultAndroidExportDirectory, + ) ?? + ''; + } + return await getDirectoryPath() ?? ''; + } +} diff --git a/lib/service/notifying.dart b/lib/service/notifying.dart index bb8838a..4870c2b 100644 --- a/lib/service/notifying.dart +++ b/lib/service/notifying.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import '../l10n/app_localizations.dart'; import 'url_launcher.dart' show UrlLaunchErrorCode; class Notifying { @@ -46,10 +47,18 @@ class Notifying { if (errorCode == UrlLaunchErrorCode.none) { return; } else if (errorCode == UrlLaunchErrorCode.couldNotLaunch) { - errorText = 'Could not launch Url'; + errorText = AppLocalizations.of(context)!.errorCouldNotLaunchUrl; } else { - errorText = 'Invalid Url'; + errorText = AppLocalizations.of(context)!.errorInvalidUrl; } showSnackbar(context, text: errorText, isError: true); } + + static void showErrorSnackbar(BuildContext context, String message) { + showSnackbar(context, text: message, isError: true); + } + + static void showMessageSnackbar(BuildContext context, String message) { + showSnackbar(context, text: message, isError: false); + } } diff --git a/lib/service/permission_service.dart b/lib/service/permission_service.dart new file mode 100644 index 0000000..726ff54 --- /dev/null +++ b/lib/service/permission_service.dart @@ -0,0 +1,9 @@ +import 'package:permission_handler/permission_handler.dart'; + +class PermissionService { + static Future get storagePermissionStatus => + Permission.manageExternalStorage.status; + + static Future get requestStoragePermission => + Permission.manageExternalStorage.request(); +} diff --git a/lib/service/storage.dart b/lib/service/storage.dart index ef91ac2..8ae8bac 100644 --- a/lib/service/storage.dart +++ b/lib/service/storage.dart @@ -4,6 +4,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../model/bookmark.dart'; import '../model/collection.dart'; +import 'json_file_service.dart'; class Storage { static const String _bookmarksKey = 'bookmarks'; @@ -163,4 +164,20 @@ class Storage { } return _prefsWithCache!; } + + static Future exportToJsonFile() => JsonFileService.exportToJson( + collections: loadCollections(), + bookmarks: loadBookmarks(), + ); + + static Future importFromJsonFile() async { + final import = await JsonFileService.importFromJson(); + + if (import.bookmarks.isNotEmpty || import.collections.isNotEmpty) { + saveBookmarks(import.bookmarks); + saveCollections(import.collections); + return true; + } + return false; + } } diff --git a/pubspec.lock b/pubspec.lock index a69ba4a..d5c5720 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -49,6 +49,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "701dcfc06da0882883a2657c445103380e53e647060ad8d9dfb710c100996608" + url: "https://pub.dev" + source: hosted + version: "0.3.5+1" csslib: dependency: transitive description: @@ -89,6 +97,70 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + file_selector: + dependency: "direct main" + description: + name: file_selector + sha256: bd15e43e9268db636b53eeaca9f56324d1622af30e5c34d6e267649758c84d9a + url: "https://pub.dev" + source: hosted + version: "1.1.0" + file_selector_android: + dependency: transitive + description: + name: file_selector_android + sha256: "51e8fd0446de75e4b62c065b76db2210c704562d072339d333bd89c57a7f8a7c" + url: "https://pub.dev" + source: hosted + version: "0.5.2+4" + file_selector_ios: + dependency: transitive + description: + name: file_selector_ios + sha256: e2ecf2885c121691ce13b60db3508f53c01f869fb6e8dc5c1cfa771e4c46aeca + url: "https://pub.dev" + source: hosted + version: "0.5.3+5" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + url: "https://pub.dev" + source: hosted + version: "0.9.4" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_web: + dependency: transitive + description: + name: file_selector_web + sha256: c4c0ea4224d97a60a7067eca0c8fd419e708ff830e0c83b11a48faf566cec3e7 + url: "https://pub.dev" + source: hosted + version: "0.9.4+2" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + url: "https://pub.dev" + source: hosted + version: "0.9.3+5" flutter: dependency: "direct main" description: flutter @@ -253,6 +325,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1 + url: "https://pub.dev" + source: hosted + version: "12.0.1" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6" + url: "https://pub.dev" + source: hosted + version: "13.0.1" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023 + url: "https://pub.dev" + source: hosted + version: "9.4.7" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" + url: "https://pub.dev" + source: hosted + version: "0.1.3+5" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878 + url: "https://pub.dev" + source: hosted + version: "4.3.0" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" + url: "https://pub.dev" + source: hosted + version: "0.2.1" platform: dependency: transitive description: @@ -499,5 +619,5 @@ packages: source: hosted version: "1.1.0" sdks: - dart: ">=3.9.2 <4.0.0" - flutter: ">=3.35.0" + dart: ">=3.10.0 <4.0.0" + flutter: ">=3.38.0" diff --git a/pubspec.yaml b/pubspec.yaml index fd4ae02..7f935b7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -21,6 +21,8 @@ dependencies: flutter_localizations: sdk: flutter intl: any + permission_handler: ^12.0.1 + file_selector: ^1.1.0 dev_dependencies: flutter_test: