소스 검색

Add an option to download multiple items (#1563)

## Description

<img width="373" alt="Screenshot 2024-04-30 at 4 06 33 PM"
src="https://github.com/ente-io/ente/assets/1161789/f4bc463e-654d-4e5f-8d7d-27308149068b">

## Tests

- [x] Tested on Simulator

> Note: If the downloaded item was not owned by the user, but was shared
with them, it will get re-uploaded into the user's own account. This is
the existing behavior, so have left it untouched. Will wait for customer
feedback before updating the implementation to ignore such items.
Vishnu Mohandas 1 년 전
부모
커밋
f00a04710b

+ 1 - 0
mobile/lib/db/files_db.dart

@@ -455,6 +455,7 @@ class FilesDB {
   }
   }
 
 
   Future<int> insert(EnteFile file) async {
   Future<int> insert(EnteFile file) async {
+    _logger.info("Inserting $file");
     final db = await instance.database;
     final db = await instance.database;
     return db.insert(
     return db.insert(
       filesTable,
       filesTable,

+ 2 - 0
mobile/lib/generated/intl/messages_en.dart

@@ -721,6 +721,8 @@ class MessageLookup extends MessageLookupByLibrary {
         "filesBackedUpFromDevice": m22,
         "filesBackedUpFromDevice": m22,
         "filesBackedUpInAlbum": m23,
         "filesBackedUpInAlbum": m23,
         "filesDeleted": MessageLookupByLibrary.simpleMessage("Files deleted"),
         "filesDeleted": MessageLookupByLibrary.simpleMessage("Files deleted"),
+        "filesSavedToGallery":
+            MessageLookupByLibrary.simpleMessage("Files saved to gallery"),
         "flip": MessageLookupByLibrary.simpleMessage("Flip"),
         "flip": MessageLookupByLibrary.simpleMessage("Flip"),
         "forYourMemories":
         "forYourMemories":
             MessageLookupByLibrary.simpleMessage("for your memories"),
             MessageLookupByLibrary.simpleMessage("for your memories"),

+ 10 - 0
mobile/lib/generated/l10n.dart

@@ -5945,6 +5945,16 @@ class S {
     );
     );
   }
   }
 
 
+  /// `Files saved to gallery`
+  String get filesSavedToGallery {
+    return Intl.message(
+      'Files saved to gallery',
+      name: 'filesSavedToGallery',
+      desc: '',
+      args: [],
+    );
+  }
+
   /// `Failed to save file to gallery`
   /// `Failed to save file to gallery`
   String get fileFailedToSaveToGallery {
   String get fileFailedToSaveToGallery {
     return Intl.message(
     return Intl.message(

+ 1 - 0
mobile/lib/l10n/intl_en.arb

@@ -835,6 +835,7 @@
   "close": "Close",
   "close": "Close",
   "setAs": "Set as",
   "setAs": "Set as",
   "fileSavedToGallery": "File saved to gallery",
   "fileSavedToGallery": "File saved to gallery",
+  "filesSavedToGallery": "Files saved to gallery",
   "fileFailedToSaveToGallery": "Failed to save file to gallery",
   "fileFailedToSaveToGallery": "Failed to save file to gallery",
   "download": "Download",
   "download": "Download",
   "pressAndHoldToPlayVideo": "Press and hold to play video",
   "pressAndHoldToPlayVideo": "Press and hold to play video",

+ 1 - 1
mobile/lib/models/file/file.dart

@@ -308,7 +308,7 @@ class EnteFile {
   @override
   @override
   String toString() {
   String toString() {
     return '''File(generatedID: $generatedID, localID: $localID, title: $title, 
     return '''File(generatedID: $generatedID, localID: $localID, title: $title, 
-      uploadedFileId: $uploadedFileID, modificationTime: $modificationTime, 
+      type: $fileType, uploadedFileId: $uploadedFileID, modificationTime: $modificationTime, 
       ownerID: $ownerID, collectionID: $collectionID, updationTime: $updationTime)''';
       ownerID: $ownerID, collectionID: $collectionID, updationTime: $updationTime)''';
   }
   }
 
 

+ 67 - 31
mobile/lib/ui/viewer/actions/file_selection_actions_widget.dart

@@ -3,6 +3,7 @@ import "dart:async";
 import 'package:fast_base58/fast_base58.dart';
 import 'package:fast_base58/fast_base58.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
 import 'package:flutter/services.dart';
+import "package:logging/logging.dart";
 import "package:modal_bottom_sheet/modal_bottom_sheet.dart";
 import "package:modal_bottom_sheet/modal_bottom_sheet.dart";
 import 'package:photos/core/configuration.dart';
 import 'package:photos/core/configuration.dart';
 import "package:photos/generated/l10n.dart";
 import "package:photos/generated/l10n.dart";
@@ -30,6 +31,8 @@ import 'package:photos/ui/sharing/manage_links_widget.dart';
 import "package:photos/ui/tools/collage/collage_creator_page.dart";
 import "package:photos/ui/tools/collage/collage_creator_page.dart";
 import "package:photos/ui/viewer/location/update_location_data_widget.dart";
 import "package:photos/ui/viewer/location/update_location_data_widget.dart";
 import 'package:photos/utils/delete_file_util.dart';
 import 'package:photos/utils/delete_file_util.dart';
+import "package:photos/utils/dialog_util.dart";
+import "package:photos/utils/file_download_util.dart";
 import 'package:photos/utils/magic_util.dart';
 import 'package:photos/utils/magic_util.dart';
 import 'package:photos/utils/navigation_util.dart';
 import 'package:photos/utils/navigation_util.dart';
 import "package:photos/utils/share_util.dart";
 import "package:photos/utils/share_util.dart";
@@ -56,6 +59,7 @@ class FileSelectionActionsWidget extends StatefulWidget {
 
 
 class _FileSelectionActionsWidgetState
 class _FileSelectionActionsWidgetState
     extends State<FileSelectionActionsWidget> {
     extends State<FileSelectionActionsWidget> {
+  static final _logger = Logger("FileSelectionActionsWidget");
   late int currentUserID;
   late int currentUserID;
   late FilesSplit split;
   late FilesSplit split;
   late CollectionActions collectionActions;
   late CollectionActions collectionActions;
@@ -115,6 +119,8 @@ class _FileSelectionActionsWidgetState
         !widget.selectedFiles.files.any(
         !widget.selectedFiles.files.any(
           (element) => element.fileType == FileType.video,
           (element) => element.fileType == FileType.video,
         );
         );
+    final showDownloadOption =
+        widget.selectedFiles.files.any((element) => element.localID == null);
 
 
     //To animate adding and removing of [SelectedActionButton], add all items
     //To animate adding and removing of [SelectedActionButton], add all items
     //and set [shouldShow] to false for items that should not be shown and true
     //and set [shouldShow] to false for items that should not be shown and true
@@ -367,6 +373,16 @@ class _FileSelectionActionsWidgetState
       );
       );
     }
     }
 
 
+    if (showDownloadOption) {
+      items.add(
+        SelectionActionButton(
+          labelText: S.of(context).download,
+          icon: Icons.cloud_download_outlined,
+          onTap: () => _download(widget.selectedFiles.files.toList()),
+        ),
+      );
+    }
+
     items.add(
     items.add(
       SelectionActionButton(
       SelectionActionButton(
         labelText: S.of(context).share,
         labelText: S.of(context).share,
@@ -379,41 +395,36 @@ class _FileSelectionActionsWidgetState
       ),
       ),
     );
     );
 
 
-    if (items.isNotEmpty) {
-      final scrollController = ScrollController();
-      // h4ck: https://github.com/flutter/flutter/issues/57920#issuecomment-893970066
-      return MediaQuery(
-        data: MediaQuery.of(context).removePadding(removeBottom: true),
-        child: SafeArea(
-          child: Scrollbar(
-            radius: const Radius.circular(1),
-            thickness: 2,
-            controller: scrollController,
-            thumbVisibility: true,
-            child: SingleChildScrollView(
-              physics: const BouncingScrollPhysics(
-                decelerationRate: ScrollDecelerationRate.fast,
-              ),
-              scrollDirection: Axis.horizontal,
-              child: Container(
-                padding: const EdgeInsets.only(bottom: 24),
-                child: Row(
-                  crossAxisAlignment: CrossAxisAlignment.start,
-                  children: [
-                    const SizedBox(width: 4),
-                    ...items,
-                    const SizedBox(width: 4),
-                  ],
-                ),
+    final scrollController = ScrollController();
+    // h4ck: https://github.com/flutter/flutter/issues/57920#issuecomment-893970066
+    return MediaQuery(
+      data: MediaQuery.of(context).removePadding(removeBottom: true),
+      child: SafeArea(
+        child: Scrollbar(
+          radius: const Radius.circular(1),
+          thickness: 2,
+          controller: scrollController,
+          thumbVisibility: true,
+          child: SingleChildScrollView(
+            physics: const BouncingScrollPhysics(
+              decelerationRate: ScrollDecelerationRate.fast,
+            ),
+            scrollDirection: Axis.horizontal,
+            child: Container(
+              padding: const EdgeInsets.only(bottom: 24),
+              child: Row(
+                crossAxisAlignment: CrossAxisAlignment.start,
+                children: [
+                  const SizedBox(width: 4),
+                  ...items,
+                  const SizedBox(width: 4),
+                ],
               ),
               ),
             ),
             ),
           ),
           ),
         ),
         ),
-      );
-    } else {
-      // TODO: Return "Select All" here
-      return const SizedBox.shrink();
-    }
+      ),
+    );
   }
   }
 
 
   Future<void> _moveFiles() async {
   Future<void> _moveFiles() async {
@@ -647,4 +658,29 @@ class _FileSelectionActionsWidgetState
       widget.selectedFiles.clearAll();
       widget.selectedFiles.clearAll();
     }
     }
   }
   }
+
+  Future<void> _download(List<EnteFile> files) async {
+    final dialog = createProgressDialog(
+      context,
+      S.of(context).downloading,
+      isDismissible: true,
+    );
+    await dialog.show();
+    try {
+      final futures = <Future>[];
+      for (final file in files) {
+        if (file.localID == null) {
+          futures.add(downloadToGallery(file));
+        }
+      }
+      await Future.wait(futures);
+      await dialog.hide();
+      widget.selectedFiles.clearAll();
+      showToast(context, S.of(context).filesSavedToGallery);
+    } catch (e) {
+      _logger.warning("Failed to save files", e);
+      await dialog.hide();
+      await showGenericErrorDialog(context: context, error: e);
+    }
+  }
 }
 }

+ 3 - 92
mobile/lib/ui/viewer/file/file_app_bar.dart

@@ -4,30 +4,23 @@ import 'package:flutter/cupertino.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/material.dart';
 import 'package:logging/logging.dart';
 import 'package:logging/logging.dart';
 import 'package:media_extension/media_extension.dart';
 import 'package:media_extension/media_extension.dart';
-import 'package:path/path.dart' as file_path;
-import 'package:photo_manager/photo_manager.dart';
-import 'package:photos/core/event_bus.dart';
-import 'package:photos/db/files_db.dart';
-import 'package:photos/events/local_photos_updated_event.dart';
 import "package:photos/generated/l10n.dart";
 import "package:photos/generated/l10n.dart";
 import "package:photos/l10n/l10n.dart";
 import "package:photos/l10n/l10n.dart";
 import "package:photos/models/file/extensions/file_props.dart";
 import "package:photos/models/file/extensions/file_props.dart";
 import 'package:photos/models/file/file.dart';
 import 'package:photos/models/file/file.dart';
 import 'package:photos/models/file/file_type.dart';
 import 'package:photos/models/file/file_type.dart';
 import 'package:photos/models/file/trash_file.dart';
 import 'package:photos/models/file/trash_file.dart';
-import 'package:photos/models/ignored_file.dart';
 import "package:photos/models/metadata/common_keys.dart";
 import "package:photos/models/metadata/common_keys.dart";
 import 'package:photos/models/selected_files.dart';
 import 'package:photos/models/selected_files.dart';
 import "package:photos/service_locator.dart";
 import "package:photos/service_locator.dart";
 import 'package:photos/services/collections_service.dart';
 import 'package:photos/services/collections_service.dart';
 import 'package:photos/services/hidden_service.dart';
 import 'package:photos/services/hidden_service.dart';
-import 'package:photos/services/ignored_files_service.dart';
-import 'package:photos/services/local_sync_service.dart';
 import 'package:photos/ui/collections/collection_action_sheet.dart';
 import 'package:photos/ui/collections/collection_action_sheet.dart';
 import 'package:photos/ui/viewer/file/custom_app_bar.dart';
 import 'package:photos/ui/viewer/file/custom_app_bar.dart';
 import "package:photos/ui/viewer/file_details/favorite_widget.dart";
 import "package:photos/ui/viewer/file_details/favorite_widget.dart";
 import "package:photos/ui/viewer/file_details/upload_icon_widget.dart";
 import "package:photos/ui/viewer/file_details/upload_icon_widget.dart";
 import 'package:photos/utils/dialog_util.dart';
 import 'package:photos/utils/dialog_util.dart';
+import "package:photos/utils/file_download_util.dart";
 import 'package:photos/utils/file_util.dart';
 import 'package:photos/utils/file_util.dart';
 import "package:photos/utils/magic_util.dart";
 import "package:photos/utils/magic_util.dart";
 import 'package:photos/utils/toast_util.dart';
 import 'package:photos/utils/toast_util.dart';
@@ -165,7 +158,7 @@ class FileAppBarState extends State<FileAppBar> {
               Icon(
               Icon(
                 Platform.isAndroid
                 Platform.isAndroid
                     ? Icons.download
                     ? Icons.download
-                    : CupertinoIcons.cloud_download,
+                    : Icons.cloud_download_outlined,
                 color: Theme.of(context).iconTheme.color,
                 color: Theme.of(context).iconTheme.color,
               ),
               ),
               const Padding(
               const Padding(
@@ -330,98 +323,16 @@ class FileAppBarState extends State<FileAppBar> {
     );
     );
     await dialog.show();
     await dialog.show();
     try {
     try {
-      final FileType type = file.fileType;
-      final bool downloadLivePhotoOnDroid =
-          type == FileType.livePhoto && Platform.isAndroid;
-      AssetEntity? savedAsset;
-      final File? fileToSave = await getFile(file);
-      //Disabling notifications for assets changing to insert the file into
-      //files db before triggering a sync.
-      await PhotoManager.stopChangeNotify();
-      if (type == FileType.image) {
-        savedAsset = await PhotoManager.editor
-            .saveImageWithPath(fileToSave!.path, title: file.title!);
-      } else if (type == FileType.video) {
-        savedAsset = await PhotoManager.editor
-            .saveVideo(fileToSave!, title: file.title!);
-      } else if (type == FileType.livePhoto) {
-        final File? liveVideoFile =
-            await getFileFromServer(file, liveVideo: true);
-        if (liveVideoFile == null) {
-          throw AssertionError("Live video can not be null");
-        }
-        if (downloadLivePhotoOnDroid) {
-          await _saveLivePhotoOnDroid(fileToSave!, liveVideoFile, file);
-        } else {
-          savedAsset = await PhotoManager.editor.darwin.saveLivePhoto(
-            imageFile: fileToSave!,
-            videoFile: liveVideoFile,
-            title: file.title!,
-          );
-        }
-      }
-
-      if (savedAsset != null) {
-        file.localID = savedAsset.id;
-        await FilesDB.instance.insert(file);
-        Bus.instance.fire(
-          LocalPhotosUpdatedEvent(
-            [file],
-            source: "download",
-          ),
-        );
-      } else if (!downloadLivePhotoOnDroid && savedAsset == null) {
-        _logger.severe('Failed to save assert of type $type');
-      }
+      await downloadToGallery(file);
       showToast(context, S.of(context).fileSavedToGallery);
       showToast(context, S.of(context).fileSavedToGallery);
       await dialog.hide();
       await dialog.hide();
     } catch (e) {
     } catch (e) {
       _logger.warning("Failed to save file", e);
       _logger.warning("Failed to save file", e);
       await dialog.hide();
       await dialog.hide();
       await showGenericErrorDialog(context: context, error: e);
       await showGenericErrorDialog(context: context, error: e);
-    } finally {
-      await PhotoManager.startChangeNotify();
-      LocalSyncService.instance.checkAndSync().ignore();
     }
     }
   }
   }
 
 
-  Future<void> _saveLivePhotoOnDroid(
-    File image,
-    File video,
-    EnteFile enteFile,
-  ) async {
-    debugPrint("Downloading LivePhoto on Droid");
-    AssetEntity? savedAsset = await (PhotoManager.editor
-        .saveImageWithPath(image.path, title: enteFile.title!));
-    if (savedAsset == null) {
-      throw Exception("Failed to save image of live photo");
-    }
-    IgnoredFile ignoreVideoFile = IgnoredFile(
-      savedAsset.id,
-      savedAsset.title ?? '',
-      savedAsset.relativePath ?? 'remoteDownload',
-      "remoteDownload",
-    );
-    await IgnoredFilesService.instance.cacheAndInsert([ignoreVideoFile]);
-    final videoTitle = file_path.basenameWithoutExtension(enteFile.title!) +
-        file_path.extension(video.path);
-    savedAsset = (await (PhotoManager.editor.saveVideo(
-      video,
-      title: videoTitle,
-    )));
-    if (savedAsset == null) {
-      throw Exception("Failed to save video of live photo");
-    }
-
-    ignoreVideoFile = IgnoredFile(
-      savedAsset.id,
-      savedAsset.title ?? videoTitle,
-      savedAsset.relativePath ?? 'remoteDownload',
-      "remoteDownload",
-    );
-    await IgnoredFilesService.instance.cacheAndInsert([ignoreVideoFile]);
-  }
-
   Future<void> _setAs(EnteFile file) async {
   Future<void> _setAs(EnteFile file) async {
     final dialog = createProgressDialog(context, S.of(context).pleaseWait);
     final dialog = createProgressDialog(context, S.of(context).pleaseWait);
     await dialog.show();
     await dialog.show();

+ 100 - 0
mobile/lib/utils/file_download_util.dart

@@ -4,14 +4,23 @@ import "package:computer/computer.dart";
 import 'package:dio/dio.dart';
 import 'package:dio/dio.dart';
 import "package:flutter/foundation.dart";
 import "package:flutter/foundation.dart";
 import 'package:logging/logging.dart';
 import 'package:logging/logging.dart';
+import 'package:path/path.dart' as file_path;
+import "package:photo_manager/photo_manager.dart";
 import 'package:photos/core/configuration.dart';
 import 'package:photos/core/configuration.dart';
+import "package:photos/core/event_bus.dart";
 import 'package:photos/core/network/network.dart';
 import 'package:photos/core/network/network.dart';
+import "package:photos/db/files_db.dart";
+import "package:photos/events/local_photos_updated_event.dart";
 import 'package:photos/models/file/file.dart';
 import 'package:photos/models/file/file.dart';
 import "package:photos/models/file/file_type.dart";
 import "package:photos/models/file/file_type.dart";
+import "package:photos/models/ignored_file.dart";
 import 'package:photos/services/collections_service.dart';
 import 'package:photos/services/collections_service.dart';
+import "package:photos/services/ignored_files_service.dart";
+import "package:photos/services/local_sync_service.dart";
 import 'package:photos/utils/crypto_util.dart';
 import 'package:photos/utils/crypto_util.dart';
 import "package:photos/utils/data_util.dart";
 import "package:photos/utils/data_util.dart";
 import "package:photos/utils/fake_progress.dart";
 import "package:photos/utils/fake_progress.dart";
+import "package:photos/utils/file_util.dart";
 
 
 final _logger = Logger("file_download_util");
 final _logger = Logger("file_download_util");
 
 
@@ -115,6 +124,97 @@ Future<Uint8List> getFileKeyUsingBgWorker(EnteFile file) async {
   );
   );
 }
 }
 
 
+Future<void> downloadToGallery(EnteFile file) async {
+  try {
+    final FileType type = file.fileType;
+    final bool downloadLivePhotoOnDroid =
+        type == FileType.livePhoto && Platform.isAndroid;
+    AssetEntity? savedAsset;
+    final File? fileToSave = await getFile(file);
+    //Disabling notifications for assets changing to insert the file into
+    //files db before triggering a sync.
+    await PhotoManager.stopChangeNotify();
+    if (type == FileType.image) {
+      savedAsset = await PhotoManager.editor
+          .saveImageWithPath(fileToSave!.path, title: file.title!);
+    } else if (type == FileType.video) {
+      savedAsset =
+          await PhotoManager.editor.saveVideo(fileToSave!, title: file.title!);
+    } else if (type == FileType.livePhoto) {
+      final File? liveVideoFile =
+          await getFileFromServer(file, liveVideo: true);
+      if (liveVideoFile == null) {
+        throw AssertionError("Live video can not be null");
+      }
+      if (downloadLivePhotoOnDroid) {
+        await _saveLivePhotoOnDroid(fileToSave!, liveVideoFile, file);
+      } else {
+        savedAsset = await PhotoManager.editor.darwin.saveLivePhoto(
+          imageFile: fileToSave!,
+          videoFile: liveVideoFile,
+          title: file.title!,
+        );
+      }
+    }
+
+    if (savedAsset != null) {
+      file.localID = savedAsset.id;
+      await FilesDB.instance.insert(file);
+      Bus.instance.fire(
+        LocalPhotosUpdatedEvent(
+          [file],
+          source: "download",
+        ),
+      );
+    } else if (!downloadLivePhotoOnDroid && savedAsset == null) {
+      _logger.severe('Failed to save assert of type $type');
+    }
+  } catch (e) {
+    _logger.severe("Failed to save file", e);
+    rethrow;
+  } finally {
+    await PhotoManager.startChangeNotify();
+    LocalSyncService.instance.checkAndSync().ignore();
+  }
+}
+
+Future<void> _saveLivePhotoOnDroid(
+  File image,
+  File video,
+  EnteFile enteFile,
+) async {
+  debugPrint("Downloading LivePhoto on Droid");
+  AssetEntity? savedAsset = await (PhotoManager.editor
+      .saveImageWithPath(image.path, title: enteFile.title!));
+  if (savedAsset == null) {
+    throw Exception("Failed to save image of live photo");
+  }
+  IgnoredFile ignoreVideoFile = IgnoredFile(
+    savedAsset.id,
+    savedAsset.title ?? '',
+    savedAsset.relativePath ?? 'remoteDownload',
+    "remoteDownload",
+  );
+  await IgnoredFilesService.instance.cacheAndInsert([ignoreVideoFile]);
+  final videoTitle = file_path.basenameWithoutExtension(enteFile.title!) +
+      file_path.extension(video.path);
+  savedAsset = (await (PhotoManager.editor.saveVideo(
+    video,
+    title: videoTitle,
+  )));
+  if (savedAsset == null) {
+    throw Exception("Failed to save video of live photo");
+  }
+
+  ignoreVideoFile = IgnoredFile(
+    savedAsset.id,
+    savedAsset.title ?? videoTitle,
+    savedAsset.relativePath ?? 'remoteDownload',
+    "remoteDownload",
+  );
+  await IgnoredFilesService.instance.cacheAndInsert([ignoreVideoFile]);
+}
+
 Uint8List _decryptFileKey(Map<String, dynamic> args) {
 Uint8List _decryptFileKey(Map<String, dynamic> args) {
   final encryptedKey = CryptoUtil.base642bin(args["encryptedKey"]);
   final encryptedKey = CryptoUtil.base642bin(args["encryptedKey"]);
   final nonce = CryptoUtil.base642bin(args["keyDecryptionNonce"]);
   final nonce = CryptoUtil.base642bin(args["keyDecryptionNonce"]);

+ 2 - 2
mobile/pubspec.lock

@@ -342,10 +342,10 @@ packages:
     dependency: "direct main"
     dependency: "direct main"
     description:
     description:
       name: cupertino_icons
       name: cupertino_icons
-      sha256: d57953e10f9f8327ce64a508a355f0b1ec902193f66288e8cb5070e7c47eeb2d
+      sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6
       url: "https://pub.dev"
       url: "https://pub.dev"
     source: hosted
     source: hosted
-    version: "1.0.6"
+    version: "1.0.8"
   dart_style:
   dart_style:
     dependency: transitive
     dependency: transitive
     description:
     description:

+ 1 - 1
mobile/pubspec.yaml

@@ -39,7 +39,7 @@ dependencies:
   connectivity_plus: ^6.0.2
   connectivity_plus: ^6.0.2
   cross_file: ^0.3.3
   cross_file: ^0.3.3
   crypto: ^3.0.2
   crypto: ^3.0.2
-  cupertino_icons: ^1.0.0
+  cupertino_icons: ^1.0.8
   defer_pointer: ^0.0.2
   defer_pointer: ^0.0.2
   device_info_plus: ^9.0.3
   device_info_plus: ^9.0.3
   dio: ^4.0.6
   dio: ^4.0.6