From a4cdd92f88ad17ef3afd308c3366c99b8e5b8089 Mon Sep 17 00:00:00 2001 From: Nutcake Date: Fri, 23 Jun 2023 22:23:46 +0200 Subject: [PATCH] Add inventory actions --- lib/apis/record_api.dart | 5 + lib/clients/inventory_client.dart | 88 +++++- lib/config.dart | 2 +- lib/main.dart | 22 +- lib/string_formatter.dart | 5 + lib/widgets/inventory/inventory_browser.dart | 139 ++++------ .../inventory/inventory_browser_app_bar.dart | 253 +++++++++++++++++- .../inventory/path_inventory_tile.dart | 41 +-- pubspec.lock | 8 + pubspec.yaml | 1 + 10 files changed, 437 insertions(+), 127 deletions(-) diff --git a/lib/apis/record_api.dart b/lib/apis/record_api.dart index 275f8dd..7575c20 100644 --- a/lib/apis/record_api.dart +++ b/lib/apis/record_api.dart @@ -31,6 +31,11 @@ class RecordApi { return body.map((e) => Record.fromMap(e)).toList(); } + static Future deleteRecord(ApiClient client, {required String recordId}) async { + final response = await client.delete("/users/${client.userId}/records/$recordId"); + client.checkResponse(response); + } + static Future preprocessRecord(ApiClient client, {required Record record}) async { final body = jsonEncode(record.toMap()); final response = await client.post( diff --git a/lib/clients/inventory_client.dart b/lib/clients/inventory_client.dart index 91df967..930612f 100644 --- a/lib/clients/inventory_client.dart +++ b/lib/clients/inventory_client.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:contacts_plus_plus/apis/record_api.dart'; import 'package:contacts_plus_plus/clients/api_client.dart'; import 'package:contacts_plus_plus/models/inventory/neos_path.dart'; @@ -13,11 +15,46 @@ class InventoryClient extends ChangeNotifier { InventoryClient({required this.apiClient}); + final Map _selectedRecords = {}; + + List get selectedRecords => _selectedRecords.values.toList(); + + bool get isAnyRecordSelected => _selectedRecords.isNotEmpty; + + bool isRecordSelected(Record record) => _selectedRecords.containsKey(record.id); + + int get selectedRecordCount => _selectedRecords.length; + + bool get onlyFilesSelected => _selectedRecords.values + .every((element) => element.recordType != RecordType.link && element.recordType != RecordType.directory); + + void clearSelectedRecords() { + _selectedRecords.clear(); + notifyListeners(); + } + + Future deleteSelectedRecords() async { + for (final recordId in _selectedRecords.keys) { + await RecordApi.deleteRecord(apiClient, recordId: recordId); + } + _selectedRecords.clear(); + reloadCurrentDirectory(); + } + + void toggleRecordSelected(Record record) { + if (_selectedRecords.containsKey(record.id)) { + _selectedRecords.remove(record.id); + } else { + _selectedRecords[record.id] = record; + } + notifyListeners(); + } + Future> _getDirectory(Record record) async { NeosDirectory? dir; try { dir = await _currentDirectory; - } catch(_) {} + } catch (_) {} final List records; if (dir == null || record.isRoot) { records = await RecordApi.getUserRecordsAt( @@ -26,14 +63,13 @@ class InventoryClient extends ChangeNotifier { ); } else { if (record.recordType == RecordType.link) { - final linkRecord = await RecordApi.getUserRecord(apiClient, recordId: record.linkRecordId, user: record.linkOwnerId); - records = await RecordApi.getUserRecordsAt(apiClient, path: "${linkRecord.path}\\${record.name}", user: linkRecord.ownerId); + final linkRecord = + await RecordApi.getUserRecord(apiClient, recordId: record.linkRecordId, user: record.linkOwnerId); + records = await RecordApi.getUserRecordsAt(apiClient, + path: "${linkRecord.path}\\${record.name}", user: linkRecord.ownerId); } else { - records = await RecordApi.getUserRecordsAt( - apiClient, - path: "${record.path}\\${record.name}", - user: record.ownerId - ); + records = + await RecordApi.getUserRecordsAt(apiClient, path: "${record.path}\\${record.name}", user: record.ownerId); } } return records; @@ -54,6 +90,31 @@ class InventoryClient extends ChangeNotifier { }, ); _currentDirectory = rootFuture; + } + + void forceNotify() => notifyListeners(); + + Future reloadCurrentDirectory() async { + final dir = await _currentDirectory; + + if (dir == null) { + throw "Failed to reload: No directory loaded."; + } + + _currentDirectory = _getDirectory(dir.record).then( + (records) { + final children = records.map((record) => NeosDirectory.fromRecord(record: record, parent: dir)).toList(); + final newDir = NeosDirectory(record: dir.record, children: children, parent: dir.parent); + + final parentIdx = dir.parent?.children.indexOf(dir) ?? -1; + if (parentIdx != -1) { + dir.parent?.children[parentIdx] = newDir; + } + return newDir; + }, + ).onError((error, stackTrace) { + return dir; + }); notifyListeners(); } @@ -73,6 +134,8 @@ class InventoryClient extends ChangeNotifier { throw "Failed to open: Record is not a child of current directory."; } + Object? caughtError; + if (childDir.isLoaded) { _currentDirectory = Future.value(childDir); } else { @@ -83,13 +146,22 @@ class InventoryClient extends ChangeNotifier { return childDir; }, ).onError((error, stackTrace) { + caughtError = error; return dir; }); } notifyListeners(); + await _currentDirectory; + // Dirty hack to throw the error here instead of letting the FutureBuilder handle it. This means we can keep showing + // the previous directory while also being able to display the error as a snackbar. + if (caughtError != null) { + throw caughtError!; + } } Future navigateUp({int times = 1}) async { + if (times == 0) return; + var dir = await _currentDirectory; if (dir == null) { throw "Failed to navigate up: No directory loaded."; diff --git a/lib/config.dart b/lib/config.dart index d392f16..d8162d7 100644 --- a/lib/config.dart +++ b/lib/config.dart @@ -1,5 +1,5 @@ class Config { - static const String apiBaseUrl = "https://cloudx.azurewebsites.net"; + static const String apiBaseUrl = "https://api.neos.com"; static const String legacyCloudUrl = "https://neoscloud.blob.core.windows.net/assets/"; static const String blobStorageUrl = "https://cloudxstorage.blob.core.windows.net/assets/"; static const String videoStorageUrl = "https://cloudx-video.azureedge.net/"; diff --git a/lib/main.dart b/lib/main.dart index 6658d05..d9ddb7e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -8,19 +8,17 @@ import 'package:contacts_plus_plus/clients/messaging_client.dart'; import 'package:contacts_plus_plus/clients/session_client.dart'; import 'package:contacts_plus_plus/clients/settings_client.dart'; import 'package:contacts_plus_plus/models/sem_ver.dart'; -import 'package:contacts_plus_plus/widgets/friends/friends_list.dart'; import 'package:contacts_plus_plus/widgets/friends/friends_list_app_bar.dart'; import 'package:contacts_plus_plus/widgets/homepage.dart'; -import 'package:contacts_plus_plus/widgets/inventory/inventory_browser.dart'; import 'package:contacts_plus_plus/widgets/inventory/inventory_browser_app_bar.dart'; import 'package:contacts_plus_plus/widgets/login_screen.dart'; -import 'package:contacts_plus_plus/widgets/sessions/session_list.dart'; import 'package:contacts_plus_plus/widgets/sessions/session_list_app_bar.dart'; import 'package:contacts_plus_plus/widgets/settings_app_bar.dart'; -import 'package:contacts_plus_plus/widgets/settings_page.dart'; import 'package:contacts_plus_plus/widgets/update_notifier.dart'; import 'package:dynamic_color/dynamic_color.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_downloader/flutter_downloader.dart'; import 'package:flutter_phoenix/flutter_phoenix.dart'; import 'package:hive_flutter/hive_flutter.dart'; import 'package:intl/intl.dart'; @@ -32,20 +30,29 @@ import 'models/authentication_data.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); + await FlutterDownloader.initialize( + debug: kDebugMode, + ); + Provider.debugCheckInvalidValueType = null; + await Hive.initFlutter(); + final dateFormat = DateFormat.Hms(); Logger.root.onRecord.listen( (event) => log("${dateFormat.format(event.time)}: ${event.message}", name: event.loggerName, time: event.time)); + final settingsClient = SettingsClient(); await settingsClient.loadSettings(); final newSettings = settingsClient.currentSettings.copyWith(machineId: settingsClient.currentSettings.machineId.valueOrDefault); await settingsClient.changeSettings(newSettings); // Save generated machineId to disk + AuthenticationData cachedAuth = AuthenticationData.unauthenticated(); try { cachedAuth = await ApiClient.tryCachedLogin(); } catch (_) {} + runApp(ContactsPlusPlus(settingsClient: settingsClient, cachedAuthentication: cachedAuth)); } @@ -60,13 +67,6 @@ class ContactsPlusPlus extends StatefulWidget { } class _ContactsPlusPlusState extends State { - static const List _appBars = [ - FriendsListAppBar(), - SessionListAppBar(), - InventoryBrowserAppBar(), - SettingsAppBar() - ]; - final Typography _typography = Typography.material2021(platform: TargetPlatform.android); late AuthenticationData _authData = widget.cachedAuthentication; bool _checkedForUpdate = false; diff --git a/lib/string_formatter.dart b/lib/string_formatter.dart index 55c54d9..e853894 100644 --- a/lib/string_formatter.dart +++ b/lib/string_formatter.dart @@ -57,6 +57,11 @@ class FormatNode { return spanTree; } + @override + String toString() { + return text + children.join(); + } + static FormatNode buildFromStyles(List styles, String text) { if (styles.isEmpty) return FormatNode(format: FormatData.unformatted(), children: [], text: text); final root = FormatNode(text: "", format: styles.first, children: []); diff --git a/lib/widgets/inventory/inventory_browser.dart b/lib/widgets/inventory/inventory_browser.dart index e81fce8..653c460 100644 --- a/lib/widgets/inventory/inventory_browser.dart +++ b/lib/widgets/inventory/inventory_browser.dart @@ -22,7 +22,6 @@ class InventoryBrowser extends StatefulWidget { class _InventoryBrowserState extends State with AutomaticKeepAliveClientMixin { static const Duration _refreshLimit = Duration(seconds: 60); - final Set _selectedIds = {}; Timer? _refreshLimiter; @override @@ -57,7 +56,7 @@ class _InventoryBrowserState extends State with AutomaticKeepA onRefresh: () async { if (_refreshLimiter?.isActive ?? false) return; try { - //TODO: Reload path + await iClient.reloadCurrentDirectory(); _refreshLimiter = Timer(_refreshLimit, () {}); } catch (e) { ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Refresh failed: $e"))); @@ -72,6 +71,7 @@ class _InventoryBrowserState extends State with AutomaticKeepA message: snapshot.error.toString(), onRetry: () { iClient.loadInventoryRoot(); + iClient.forceNotify(); }, ); } @@ -93,33 +93,34 @@ class _InventoryBrowserState extends State with AutomaticKeepA ListView( children: [ Padding( - padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8), - child: Wrap( - children: pathSegments - .mapIndexed( - (idx, segment) => Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (idx != 0) const Icon(Icons.chevron_right), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 4.0), - child: TextButton( - style: TextButton.styleFrom( - foregroundColor: idx == pathSegments.length - 1 - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.onSurface, - ), - onPressed: () { - iClient.navigateUp(times: pathSegments.length - 1 - idx); - }, - child: Text(segment), + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8), + child: Wrap( + children: pathSegments + .mapIndexed( + (idx, segment) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (idx != 0) const Icon(Icons.chevron_right), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: TextButton( + style: TextButton.styleFrom( + foregroundColor: idx == pathSegments.length - 1 + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.onSurface, ), + onPressed: () { + iClient.navigateUp(times: pathSegments.length - 1 - idx); + }, + child: Text(segment), ), - ], - ), - ) - .toList(), - )), + ), + ], + ), + ) + .toList(), + ), + ), GridView.builder( padding: const EdgeInsets.symmetric(horizontal: 8.0), physics: const NeverScrollableScrollPhysics(), @@ -127,44 +128,34 @@ class _InventoryBrowserState extends State with AutomaticKeepA itemCount: paths.length, gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( maxCrossAxisExtent: 256, - childAspectRatio: 4, - crossAxisSpacing: 8, - mainAxisSpacing: 8), + childAspectRatio: 3.5, + crossAxisSpacing: 0, + mainAxisSpacing: 0), itemBuilder: (context, index) { final record = paths[index]; - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 3.0), - child: PathInventoryTile( - record: record, - selected: _selectedIds.contains(record.id), - onTap: _selectedIds.isEmpty - ? () { - iClient.navigateTo(record); + return PathInventoryTile( + record: record, + onTap: iClient.isAnyRecordSelected + ? () {} + : () async { + try { + await iClient.navigateTo(record); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Failed to open directory: $e")), + ); + } } - : () { - setState(() { - if (_selectedIds.contains(record.id)) { - _selectedIds.remove(record.id); - } else { - _selectedIds.add(record.id); - } - }); - }, - onLongPress: () { - setState(() { - if (_selectedIds.contains(record.id)) { - _selectedIds.remove(record.id); - } else { - _selectedIds.add(record.id); - } - }); - }, - ), + }, + onLongPress: () { + iClient.toggleRecordSelected(record); + }, ); }, ), const SizedBox( - height: 8, + height: 0, ), GridView.builder( padding: const EdgeInsets.symmetric(horizontal: 8.0), @@ -174,45 +165,33 @@ class _InventoryBrowserState extends State with AutomaticKeepA gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( maxCrossAxisExtent: 256, childAspectRatio: 1, - crossAxisSpacing: 8, - mainAxisSpacing: 8, + crossAxisSpacing: 0, + mainAxisSpacing: 0, ), itemBuilder: (context, index) { final record = objects[index]; return ObjectInventoryTile( record: record, - selected: _selectedIds.contains(record.id), - onTap: _selectedIds.isEmpty + selected: iClient.isRecordSelected(record), + onTap: iClient.isAnyRecordSelected ? () async { + iClient.toggleRecordSelected(record); + } + : () async { await Navigator.push( context, MaterialPageRoute( builder: (context) => PhotoView( minScale: PhotoViewComputedScale.contained, - imageProvider: CachedNetworkImageProvider( - Aux.neosDbToHttp(record.thumbnailUri)), + imageProvider: + CachedNetworkImageProvider(Aux.neosDbToHttp(record.thumbnailUri)), heroAttributes: PhotoViewHeroAttributes(tag: record.id), ), ), ); - } - : () async { - setState(() { - if (_selectedIds.contains(record.id)) { - _selectedIds.remove(record.id); - } else { - _selectedIds.add(record.id); - } - }); }, onLongPress: () async { - setState(() { - if (_selectedIds.contains(record.id)) { - _selectedIds.remove(record.id); - } else { - _selectedIds.add(record.id); - } - }); + iClient.toggleRecordSelected(record); }, ); }, diff --git a/lib/widgets/inventory/inventory_browser_app_bar.dart b/lib/widgets/inventory/inventory_browser_app_bar.dart index fe1fd43..5722847 100644 --- a/lib/widgets/inventory/inventory_browser_app_bar.dart +++ b/lib/widgets/inventory/inventory_browser_app_bar.dart @@ -1,20 +1,249 @@ -import 'package:flutter/material.dart'; +import 'dart:isolate'; +import 'dart:ui'; -class InventoryBrowserAppBar extends StatelessWidget { +import 'package:contacts_plus_plus/auxiliary.dart'; +import 'package:contacts_plus_plus/clients/inventory_client.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_downloader/flutter_downloader.dart'; +import 'package:path/path.dart'; +import 'package:provider/provider.dart'; + +class InventoryBrowserAppBar extends StatefulWidget { const InventoryBrowserAppBar({super.key}); + @override + State createState() => _InventoryBrowserAppBarState(); +} + +class _InventoryBrowserAppBarState extends State { + final ReceivePort _port = ReceivePort(); + + @override + void initState() { + super.initState(); + + IsolateNameServer.registerPortWithName(_port.sendPort, 'downloader_send_port'); + _port.listen((dynamic data) { + // Not useful yet? idk... + String id = data[0]; + DownloadTaskStatus status = DownloadTaskStatus(data[1]); + int progress = data[2]; + }); + + FlutterDownloader.registerCallback(downloadCallback); + } + + @override + void dispose() { + IsolateNameServer.removePortNameMapping('downloader_send_port'); + super.dispose(); + } + + @pragma('vm:entry-point') + static void downloadCallback(String id, int status, int progress) { + final SendPort? send = IsolateNameServer.lookupPortByName('downloader_send_port'); + send?.send([id, status, progress]); + } + @override Widget build(BuildContext context) { - return AppBar( - title: const Text("Inventory"), - backgroundColor: Theme.of(context).colorScheme.surfaceVariant, - bottom: PreferredSize( - preferredSize: const Size.fromHeight(1), - child: Container( - height: 1, - color: Colors.black, - ), + return ChangeNotifierProvider.value( + value: Provider.of(context), + child: Consumer( + builder: (BuildContext context, InventoryClient iClient, Widget? child) { + return AnimatedSwitcher( + duration: const Duration(milliseconds: 350), + transitionBuilder: (child, animation) => FadeTransition( + opacity: animation, + child: child, + ), + child: !iClient.isAnyRecordSelected + ? AppBar( + key: const ValueKey("default-appbar"), + title: const Text("Inventory"), + ) + : AppBar( + key: const ValueKey("selection-appbar"), + title: Text("${iClient.selectedRecordCount} Selected"), + leading: IconButton( + onPressed: () { + iClient.clearSelectedRecords(); + }, + icon: const Icon(Icons.close), + ), + actions: [ + if (iClient.onlyFilesSelected) + IconButton( + onPressed: () async { + final selectedRecords = iClient.selectedRecords; + + final assetUris = selectedRecords.map((record) => record.assetUri).toList(); + final thumbUris = selectedRecords.map((record) => record.thumbnailUri).toList(); + + final selectedUris = await showDialog>( + context: context, + builder: (context) { + return AlertDialog( + icon: const Icon(Icons.download), + title: const Text("Download what?"), + content: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + const Divider(), + const SizedBox( + height: 8, + ), + TextButton.icon( + onPressed: () { + Navigator.of(context).pop(assetUris); + }, + icon: const Icon(Icons.data_object), + label: Text( + "Asset${iClient.selectedRecordCount != 1 ? "s" : ""} (${assetUris.map((e) => extension(e)).toList().unique().join(", ")})", + ), + ), + TextButton.icon( + onPressed: () { + Navigator.of(context).pop(thumbUris); + }, + icon: const Icon(Icons.image), + label: Text( + "Thumbnail${iClient.selectedRecordCount != 1 ? "s" : ""} (${thumbUris.map((e) => extension(e)).toList().unique().join(", ")})", + ), + ), + ], + ), + ); + }, + ); + if (selectedUris == null) return; + + final directory = await FilePicker.platform.getDirectoryPath(dialogTitle: "Download to..."); + if (directory == null) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text("Selection aborted."), + ), + ); + } + return; + } + if (directory == "/") { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text("Selected directory is invalid"), + ), + ); + } + return; + } + for (var record in selectedRecords) { + final uri = selectedUris == thumbUris ? record.thumbnailUri : record.thumbnailUri; + await FlutterDownloader.enqueue( + url: Aux.neosDbToHttp(uri), + savedDir: directory, + showNotification: true, + openFileFromNotification: false, + fileName: + "${record.id.split("-")[1]}-${record.formattedName.toString()}${extension(uri)}", + ); + } + iClient.clearSelectedRecords(); + }, + icon: const Icon(Icons.download), + ), + const SizedBox( + width: 4, + ), + IconButton( + onPressed: () async { + var loading = false; + await showDialog( + context: context, + builder: (context) { + return StatefulBuilder( + builder: (context, setState) { + return AlertDialog( + icon: const Icon(Icons.delete), + title: Text(iClient.selectedRecordCount == 1 + ? "Really delete this Record?" + : "Really delete ${iClient.selectedRecordCount} Records?"), + content: const Text("This action cannot be undone!"), + actionsAlignment: MainAxisAlignment.spaceBetween, + actions: [ + TextButton( + onPressed: loading + ? null + : () { + Navigator.of(context).pop(false); + }, + child: const Text("Cancel"), + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (loading) + const SizedBox.square( + dimension: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox( + width: 4, + ), + TextButton( + onPressed: loading + ? null + : () async { + setState(() { + loading = true; + }); + try { + await iClient.deleteSelectedRecords(); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text("Failed to delete one or more records: $e"), + ), + ); + } + setState(() { + loading = false; + }); + } + if (context.mounted) { + Navigator.of(context).pop(true); + } + iClient.reloadCurrentDirectory(); + }, + style: TextButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.error, + ), + child: const Text("Delete"), + ), + ], + ), + ], + ); + }, + ); + }, + ); + }, + icon: const Icon(Icons.delete), + ), + const SizedBox( + width: 4, + ), + ], + ), + ); + }, ), ); } -} \ No newline at end of file +} diff --git a/lib/widgets/inventory/path_inventory_tile.dart b/lib/widgets/inventory/path_inventory_tile.dart index bae26e5..f88977a 100644 --- a/lib/widgets/inventory/path_inventory_tile.dart +++ b/lib/widgets/inventory/path_inventory_tile.dart @@ -12,25 +12,36 @@ class PathInventoryTile extends StatelessWidget { @override Widget build(BuildContext context) { - return OutlinedButton.icon( - style: TextButton.styleFrom( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), + return Card( + elevation: 0, + shape: RoundedRectangleBorder( side: BorderSide( color: selected ? Theme.of(context).colorScheme.primary : Theme.of(context).colorScheme.outline, - width: 1, ), - foregroundColor: Theme.of(context).colorScheme.onSecondaryContainer, - alignment: Alignment.centerLeft, + borderRadius: BorderRadius.circular(16), ), - onLongPress: onLongPress, - onPressed: onTap, - icon: record.recordType == RecordType.directory ? const Icon(Icons.folder) : const Icon(Icons.link), - label: FormattedText( - record.formattedName, - maxLines: 3, - overflow: TextOverflow.ellipsis, + child: InkWell( + borderRadius: BorderRadius.circular(16), + onTap: onTap, + onLongPress: onLongPress, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 0), + child: Row( + children: [ + record.recordType == RecordType.directory ? const Icon(Icons.folder) : const Icon(Icons.link), + const SizedBox( + width: 4, + ), + Expanded( + child: FormattedText( + record.formattedName, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), ), ); } diff --git a/pubspec.lock b/pubspec.lock index eb9527d..4183ae3 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -230,6 +230,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.3.0" + flutter_downloader: + dependency: "direct main" + description: + name: flutter_downloader + sha256: "79e05335471e23593f2e22483d2c909a03f19000293cbc7f39c8c2fd4d5d9c3d" + url: "https://pub.dev" + source: hosted + version: "1.10.4" flutter_lints: dependency: "direct dev" description: diff --git a/pubspec.yaml b/pubspec.yaml index d9c769a..3e4423f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -63,6 +63,7 @@ dependencies: crypto: ^3.0.3 image_picker: ^0.8.7+5 permission_handler: ^10.2.0 + flutter_downloader: ^1.10.4 dev_dependencies: flutter_test: