diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9caafef199..8a18bdc8bf 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -34,6 +34,4 @@ jobs: run: flutter analyze - name: Test app - run: scr test - - + run: scr test \ No newline at end of file diff --git a/android/fastlane/metadata/android/en-US/changelogs/136.txt b/android/fastlane/metadata/android/en-US/changelogs/136.txt new file mode 100644 index 0000000000..9010173046 --- /dev/null +++ b/android/fastlane/metadata/android/en-US/changelogs/136.txt @@ -0,0 +1,2 @@ +- Implements thumbnail generator for all image files +- Adds the Parent Folder Name and Status columns on the Drive Export feature \ No newline at end of file diff --git a/lib/app_shell.dart b/lib/app_shell.dart index 47b5edac53..5a0ad88e1e 100644 --- a/lib/app_shell.dart +++ b/lib/app_shell.dart @@ -2,15 +2,18 @@ import 'package:ardrive/blocs/prompt_to_snapshot/prompt_to_snapshot_bloc.dart'; import 'package:ardrive/blocs/prompt_to_snapshot/prompt_to_snapshot_event.dart'; import 'package:ardrive/components/profile_card.dart'; import 'package:ardrive/components/side_bar.dart'; +import 'package:ardrive/drive_explorer/multi_thumbnail_creation/bloc/multi_thumbnail_creation_bloc.dart'; import 'package:ardrive/gift/reedem_button.dart'; import 'package:ardrive/misc/misc.dart'; import 'package:ardrive/pages/drive_detail/components/hover_widget.dart'; +import 'package:ardrive/shared/blocs/banner/app_banner_bloc.dart'; import 'package:ardrive/sync/domain/cubit/sync_cubit.dart'; import 'package:ardrive/sync/domain/sync_progress.dart'; import 'package:ardrive/utils/logger.dart'; import 'package:ardrive/utils/size_constants.dart'; import 'package:ardrive_ui/ardrive_ui.dart'; import 'package:ardrive_utils/ardrive_utils.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:responsive_builder/responsive_builder.dart'; @@ -172,24 +175,114 @@ class AppShellState extends State { ), ); return ScreenTypeLayout.builder( - desktop: (context) => buildPage( - Row( - children: [ - const AppSideBar(), - Container( - color: ArDriveTheme.of(context).themeData.backgroundColor, - width: 16, - ), - Expanded( - child: Scaffold( - backgroundColor: - ArDriveTheme.of(context).themeData.backgroundColor, - body: widget.page, - ), - ), - ], - ), - ), + desktop: (context) { + final colorTokens = + ArDriveTheme.of(context).themeData.colorTokens; + final typography = ArDriveTypographyNew.of(context); + + return buildPage( + BlocBuilder( + builder: (context, state) { + return Column( + children: [ + if (state is AppBannerVisible) + Container( + height: 45, + width: double.maxFinite, + color: colorTokens.buttonPrimaryDefault, + alignment: Alignment.center, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Spacer(), + ArDriveIcons.asc( + color: colorTokens.textOnPrimary, + size: 18, + ), + const SizedBox(width: 8), + // move two pixels above + Transform( + transform: + Matrix4.translationValues(0.0, -2.0, 0.0), + child: RichText( + text: TextSpan( + children: [ + TextSpan( + text: + 'ArDrive now supports thumbnails! You can ', + style: typography.paragraphNormal( + fontWeight: ArFontWeight.semiBold, + color: colorTokens.textOnPrimary), + ), + TextSpan( + text: 'add them now!', + recognizer: TapGestureRecognizer() + ..onTap = () { + context + .read< + MultiThumbnailCreationBloc>() + .add( + const CreateMultiThumbnailForAllDrives()); + }, + style: typography + .paragraphNormal( + fontWeight: + ArFontWeight.semiBold, + color: + colorTokens.textOnPrimary) + .copyWith( + decoration: + TextDecoration.underline, + ), + ), + ], + ), + ), + ), + const Spacer(), + Padding( + padding: const EdgeInsets.only(right: 8.0), + child: GestureDetector( + onTap: () { + context + .read() + .add(const AppBannerCloseEvent()); + }, + child: ArDriveIcons.x( + color: colorTokens.textOnPrimary, + ), + ), + ), + ], + ), + ), + Flexible( + child: Row( + children: [ + const AppSideBar(), + Container( + color: ArDriveTheme.of(context) + .themeData + .backgroundColor, + width: 16, + ), + Expanded( + child: Scaffold( + backgroundColor: ArDriveTheme.of(context) + .themeData + .backgroundColor, + body: widget.page, + ), + ), + ], + ), + ), + ], + ); + }, + ), + ); + }, mobile: (context) => buildPage(widget.page), ); }, diff --git a/lib/blocs/data_export/data_export_cubit.dart b/lib/blocs/data_export/data_export_cubit.dart index 318b706409..80696f434d 100644 --- a/lib/blocs/data_export/data_export_cubit.dart +++ b/lib/blocs/data_export/data_export_cubit.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +import 'package:ardrive/core/arfs/repository/folder_repository.dart'; import 'package:ardrive/models/models.dart'; import 'package:csv/csv.dart'; import 'package:drift/drift.dart'; @@ -8,17 +9,32 @@ import 'package:flutter_bloc/flutter_bloc.dart'; part 'data_export_state.dart'; +const _fileIdColumnName = 'File Id'; +const _fileNameColumnName = 'File Name'; +const _parentFolderIdColumnName = 'Parent Folder ID'; +const _parentFolderNameColumnName = 'Parent Folder Name'; +const _dataTransactionIdColumnName = 'Data Transaction ID'; +const _metadataTransactionIdColumnName = 'Metadata Transaction ID'; +const _fileSizeColumnName = 'File Size'; +const _dateCreatedColumnName = 'Date Created'; +const _lastModifiedColumnName = 'Last Modified'; +const _directDownloadLinkColumnName = 'Direct Download Link'; +const _statusColumnName = 'Status'; + class DataExportCubit extends Cubit { final String driveId; final DriveDao _driveDao; + final FolderRepository _folderRepository; final String _gatewayURL; DataExportCubit({ required this.driveId, required DriveDao driveDao, required String gatewayURL, + required FolderRepository folderRepository, }) : _driveDao = driveDao, _gatewayURL = gatewayURL, + _folderRepository = folderRepository, super(DataExportInitial()); Future getFilesInDriveAsCSV(String driveId) async { @@ -27,30 +43,44 @@ class DataExportCubit extends Cubit { .get(); final export = >[ [ - 'File Id', - 'File Name', - 'Parent Folder ID', - 'Data Transaction ID', - 'Metadata Transaction ID', - 'File Size', - 'Date Created', - 'Last Modified', - 'Direct Download Link' + _fileIdColumnName, + _fileNameColumnName, + _parentFolderIdColumnName, + _parentFolderNameColumnName, + _dataTransactionIdColumnName, + _metadataTransactionIdColumnName, + _fileSizeColumnName, + _dateCreatedColumnName, + _lastModifiedColumnName, + _directDownloadLinkColumnName, + _statusColumnName, ] ]; + final Map folderNames = {}; + for (var file in files) { final fileContent = []; + + final parentFolder = await _folderRepository.getLatestFolderRevisionInfo( + driveId, file.parentFolderId); + + if (parentFolder != null) { + folderNames[file.parentFolderId] = parentFolder.name; + } + fileContent ..add(file.id) ..add(file.name) ..add(file.parentFolderId) + ..add(folderNames[file.parentFolderId] ?? '') ..add(file.dataTx.id) ..add(file.metadataTx.id) ..add(file.size.toString()) ..add(file.dateCreated.toString()) ..add(file.lastModifiedDate.toString()) - ..add(Uri.parse('$_gatewayURL/${file.dataTx.id}').toString()); + ..add(Uri.parse('$_gatewayURL/${file.dataTx.id}').toString()) + ..add(file.dataTx.status.toString()); export.add(fileContent); } return const ListToCsvConverter().convert(export); diff --git a/lib/components/csv_export_dialog.dart b/lib/components/csv_export_dialog.dart index 3ac0676dfe..b99c91a28e 100644 --- a/lib/components/csv_export_dialog.dart +++ b/lib/components/csv_export_dialog.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:ardrive/blocs/blocs.dart'; import 'package:ardrive/blocs/data_export/data_export_cubit.dart'; +import 'package:ardrive/core/arfs/repository/folder_repository.dart'; import 'package:ardrive/models/models.dart'; import 'package:ardrive/services/services.dart'; import 'package:ardrive/theme/theme.dart'; @@ -23,6 +24,7 @@ Future promptToExportCSVData({ return DataExportCubit( driveId: driveId, driveDao: context.read(), + folderRepository: context.read(), gatewayURL: context.read().client.api.gatewayUrl.toString(), )..exportData(); diff --git a/lib/components/details_panel.dart b/lib/components/details_panel.dart index 4ffbf2bd93..af31b79bb6 100644 --- a/lib/components/details_panel.dart +++ b/lib/components/details_panel.dart @@ -602,15 +602,7 @@ class _DetailsPanelState extends State { leading: Row( mainAxisSize: MainAxisSize.min, children: [ - ArDriveIconButton( - tooltip: appLocalizationsOf(context).viewOnViewBlock, - icon: ArDriveIcons.newWindow(size: 20), - onPressed: () { - openUrl( - url: 'https://viewblock.io/arweave/tx/${folder.metadataTxId}', - ); - }, - ), + _TxIdTextLink(txId: folder.metadataTxId), const SizedBox(width: 12), CopyButton( text: folder.metadataTxId, @@ -671,15 +663,7 @@ class _DetailsPanelState extends State { leading: Row( mainAxisSize: MainAxisSize.min, children: [ - ArDriveIconButton( - tooltip: appLocalizationsOf(context).viewOnViewBlock, - icon: ArDriveIcons.newWindow(size: 20), - onPressed: () { - openUrl( - url: 'https://viewblock.io/arweave/tx/${state.metadataTxId}', - ); - }, - ), + _TxIdTextLink(txId: state.metadataTxId), const SizedBox(width: 12), CopyButton( text: state.metadataTxId, @@ -739,12 +723,7 @@ class _DetailsPanelState extends State { leading: Row( mainAxisSize: MainAxisSize.min, children: [ - Text( - '${state.metadataTxId.substring(0, 4)}...', - style: ArDriveTypography.body - .buttonNormalRegular() - .copyWith(decoration: TextDecoration.underline), - ), + _TxIdTextLink(txId: state.metadataTxId), const SizedBox(width: 12), CopyButton( text: state.metadataTxId, @@ -758,26 +737,7 @@ class _DetailsPanelState extends State { leading: Row( mainAxisSize: MainAxisSize.min, children: [ - // only first 4 characters of the data tx id are shown - ArDriveClickArea( - child: GestureDetector( - onTap: () { - openUrl( - url: 'https://viewblock.io/arweave/tx/${item.dataTxId}', - ); - }, - child: Tooltip( - message: item.dataTxId, - child: Text( - '${item.dataTxId.substring(0, 4)}...', - style: - ArDriveTypography.body.buttonNormalRegular().copyWith( - decoration: TextDecoration.underline, - ), - ), - ), - ), - ), + _TxIdTextLink(txId: item.dataTxId), const SizedBox(width: 12), CopyButton( text: item.dataTxId, @@ -1039,6 +999,32 @@ class _DetailsPanelState extends State { } } +class _TxIdTextLink extends StatelessWidget { + const _TxIdTextLink({required this.txId}); + + final String txId; + + @override + Widget build(BuildContext context) { + return ArDriveClickArea( + child: GestureDetector( + onTap: () { + openUrl(url: 'https://viewblock.io/arweave/tx/$txId'); + }, + child: Tooltip( + message: txId, + child: Text( + '${txId.substring(0, 4)}...', + style: ArDriveTypography.body + .buttonNormalRegular() + .copyWith(decoration: TextDecoration.underline), + ), + ), + ), + ); + } +} + class EntityRevision { final String name; final DateTime dateCreated; diff --git a/lib/components/side_bar.dart b/lib/components/side_bar.dart index 9d49d7fd79..fc04e76ca9 100644 --- a/lib/components/side_bar.dart +++ b/lib/components/side_bar.dart @@ -10,6 +10,7 @@ import 'package:ardrive/misc/resources.dart'; import 'package:ardrive/models/models.dart'; import 'package:ardrive/pages/drive_detail/components/hover_widget.dart'; import 'package:ardrive/services/config/config_service.dart'; +import 'package:ardrive/shared/blocs/banner/app_banner_bloc.dart'; import 'package:ardrive/utils/app_localizations_wrapper.dart'; import 'package:ardrive/utils/logger.dart'; import 'package:ardrive/utils/open_url.dart'; @@ -177,76 +178,86 @@ class _AppSideBarState extends State { controller: _scrollController, child: SingleChildScrollView( controller: _scrollController, - child: Container( - height: MediaQuery.of(context).size.height, - decoration: BoxDecoration( - border: Border( - right: BorderSide( - color: ArDriveTheme.of(context).themeData.colors.shadow, - width: 1, + child: BlocBuilder( + builder: (context, state) { + double height = MediaQuery.of(context).size.height; + + if (state is AppBannerVisible) { + height -= 45; + } + + return Container( + height: height, + decoration: BoxDecoration( + border: Border( + right: BorderSide( + color: ArDriveTheme.of(context).themeData.colors.shadow, + width: 1, + ), + ), ), - ), - ), - child: AnimatedSize( - duration: const Duration(milliseconds: 300), - child: SizedBox( - width: _isExpanded ? 240 : 64, - child: Column( - children: [ - Expanded( - child: Column( - children: [ - const SizedBox( - height: 24, - ), - _buildLogo(false), - const SizedBox( - height: 24, - ), - _buildDriveActionsButton( - context, - false, - ), - const SizedBox( - height: 56, + child: AnimatedSize( + duration: const Duration(milliseconds: 300), + child: SizedBox( + width: _isExpanded ? 240 : 64, + child: Column( + children: [ + Expanded( + child: Column( + children: [ + const SizedBox( + height: 24, + ), + _buildLogo(false), + const SizedBox( + height: 24, + ), + _buildDriveActionsButton( + context, + false, + ), + const SizedBox( + height: 56, + ), + _isExpanded + ? BlocBuilder( + builder: (context, state) { + if (state is DrivesLoadSuccess && + (state.userDrives.isNotEmpty || + state.sharedDrives.isNotEmpty)) { + return Flexible( + child: Padding( + padding: const EdgeInsets.only( + left: 43.0), + child: _buildAccordion( + state, + false, + ), + ), + ); + } + return const SizedBox(); + }, + ) + : const SizedBox(), + ], ), - _isExpanded - ? BlocBuilder( - builder: (context, state) { - if (state is DrivesLoadSuccess && - (state.userDrives.isNotEmpty || - state.sharedDrives.isNotEmpty)) { - return Flexible( - child: Padding( - padding: - const EdgeInsets.only(left: 43.0), - child: _buildAccordion( - state, - false, - ), - ), - ); - } - return const SizedBox(); - }, - ) - : const SizedBox(), - ], - ), - ), - const SizedBox( - height: 16, + ), + const SizedBox( + height: 16, + ), + _isExpanded + ? const SizedBox( + height: 16, + ) + : const Spacer(), + _buildSideBarBottom(), + ], ), - _isExpanded - ? const SizedBox( - height: 16, - ) - : const Spacer(), - _buildSideBarBottom(), - ], + ), ), - ), - ), + ); + }, ), ), ); diff --git a/lib/core/arfs/repository/drive_repository.dart b/lib/core/arfs/repository/drive_repository.dart new file mode 100644 index 0000000000..7039abac8b --- /dev/null +++ b/lib/core/arfs/repository/drive_repository.dart @@ -0,0 +1,33 @@ +import 'package:ardrive/authentication/ardrive_auth.dart'; +import 'package:ardrive/models/daos/daos.dart'; +import 'package:ardrive/models/database/database.dart'; + +class DriveRepository { + final DriveDao _driveDao; + final ArDriveAuth _auth; + + DriveRepository({ + required DriveDao driveDao, + required ArDriveAuth auth, + }) : _driveDao = driveDao, + _auth = auth; + + Future> getAllUserDrives() async { + final allDrives = await _driveDao.allDrives().get(); + + return allDrives + .where((element) => + element.ownerAddress == _auth.currentUser.walletAddress) + .toList(); + } + + Future> getAllFileEntriesInDrive({ + required String driveId, + }) async { + final files = await _driveDao + .filesInDriveWithRevisionTransactions(driveId: driveId) + .get(); + + return files; + } +} diff --git a/lib/core/arfs/repository/file_repository.dart b/lib/core/arfs/repository/file_repository.dart index d7411a3784..45a62d22a8 100644 --- a/lib/core/arfs/repository/file_repository.dart +++ b/lib/core/arfs/repository/file_repository.dart @@ -1,5 +1,6 @@ import 'package:ardrive/core/arfs/repository/folder_repository.dart'; import 'package:ardrive/models/daos/drive_dao/drive_dao.dart'; +import 'package:ardrive/models/models.dart'; abstract class FileRepository { Future getFilePath(String driveId, String fileId); diff --git a/lib/drive_explorer/multi_thumbnail_creation/bloc/multi_thumbnail_creation_bloc.dart b/lib/drive_explorer/multi_thumbnail_creation/bloc/multi_thumbnail_creation_bloc.dart new file mode 100644 index 0000000000..041832baef --- /dev/null +++ b/lib/drive_explorer/multi_thumbnail_creation/bloc/multi_thumbnail_creation_bloc.dart @@ -0,0 +1,222 @@ +import 'package:ardrive/core/arfs/repository/drive_repository.dart'; +import 'package:ardrive/drive_explorer/thumbnail/repository/thumbnail_repository.dart'; +import 'package:ardrive/models/models.dart'; +import 'package:ardrive/utils/constants.dart'; +import 'package:ardrive/utils/logger.dart'; +import 'package:ardrive_utils/ardrive_utils.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +part 'multi_thumbnail_creation_event.dart'; +part 'multi_thumbnail_creation_state.dart'; + +class MultiThumbnailCreationBloc + extends Bloc { + final DriveRepository _driveRepository; + final ThumbnailRepository _thumbnailRepository; + + WorkerPool? _worker; + + bool _inExecution = false; + + final List _skippedDrives = []; + + MultiThumbnailCreationBloc({ + required DriveRepository driveRepository, + required ThumbnailRepository thumbnailRepository, + }) : _thumbnailRepository = thumbnailRepository, + _driveRepository = driveRepository, + super(MultiThumbnailCreationInitial()) { + on((event, emit) async { + if (event is CreateMultiThumbnailForAllDrives) { + _worker = null; + await _createMultiThumbnailForDrive(event, emit); + } + + if (event is SkipDriveMultiThumbnailCreation) { + _skippedDrives.add((state as MultiThumbnailCreationLoadingThumbnails) + .driveInExecution! + .id); + _worker?.cancel(); + } + + if (event is CloseMultiThumbnailCreation) { + emit(MultiThumbnailClosingModal()); + } + + if (event is CancelMultiThumbnailCreation) { + _worker?.cancel(); + emit(MultiThumbnailCreationCancelled()); + _inExecution = false; + } + }); + } + + List _thumbnails = []; + + Future _createMultiThumbnailForDrive( + CreateMultiThumbnailForAllDrives event, + Emitter emit, + ) async { + try { + if (_inExecution) { + return; + } + + _inExecution = true; + + emit(MultiThumbnailCreationLoadingFiles()); + + final userDrives = await _driveRepository.getAllUserDrives(); + + if (userDrives.isEmpty) { + emit(MultiThumbnailCreationFilesLoadedEmpty()); + return; + } + + int loadedDrives = 0; + bool noMissingThumbnails = true; + + _verifyCancelAndEmitLoadingState( + state: MultiThumbnailCreationLoadingThumbnails( + thumbnailsInDrive: _thumbnails, + loadedDrives: loadedDrives, + loadedThumbnailsInDrive: 0, + numberOfDrives: userDrives.length, + driveInExecution: userDrives.first, + ), + emit: emit, + ); + + for (final drive in userDrives) { + final files = await _driveRepository.getAllFileEntriesInDrive( + driveId: drive.id, + ); + + final images = files + .where((element) => + (element.thumbnail == null || element.thumbnail == 'null') && + supportedImageTypesInFilePreview + .contains(element.dataContentType ?? '')) + .toList(); + + logger.d('Images missing thumbnails: ${images.length}'); + + images.removeWhere((element) => element.dataTx.status == 'failed'); + + if (images.isEmpty) { + loadedDrives++; + continue; + } + + noMissingThumbnails = false; + + _thumbnails = images + .map((file) => ThumbnailLoadingStatus( + file: file, + loaded: false, + )) + .toList(); + + logger.d('Thumbnails to create: ${_thumbnails.length}'); + + _verifyCancelAndEmitLoadingState( + state: MultiThumbnailCreationLoadingThumbnails( + thumbnailsInDrive: _thumbnails, + driveInExecution: drive, + loadedDrives: loadedDrives, + loadedThumbnailsInDrive: 0, + numberOfDrives: userDrives.length, + ), + emit: emit, + ); + + int loadedCount = 0; + + _worker = WorkerPool( + numWorkers: drive.isPrivate ? 1 : 2, + maxTasksPerWorker: 2, + taskQueue: List.from(_thumbnails), + execute: (thumbnail) async { + await _thumbnailRepository.uploadThumbnail( + fileId: thumbnail.file.id, + ); + + loadedCount++; + + logger.d('Thumbnail created for file ${thumbnail.file.id}'); + + final index = _thumbnails + .indexWhere((element) => element.file.id == thumbnail.file.id); + + _thumbnails[index] = ThumbnailLoadingStatus( + file: thumbnail.file, + loaded: true, + ); + + _verifyCancelAndEmitLoadingState( + state: MultiThumbnailCreationLoadingThumbnails( + thumbnailsInDrive: _thumbnails, + driveInExecution: drive, + loadedDrives: loadedDrives, + loadedThumbnailsInDrive: loadedCount, + numberOfDrives: userDrives.length, + ), + emit: emit, + ); + }, + onWorkerError: (thumbnail) { + logger.d('Error creating thumbnail for file ${thumbnail.file.id}'); + }, + ); + + await _worker?.onAllTasksCompleted; + + loadedDrives++; + } + + _inExecution = false; + + if (noMissingThumbnails) { + emit(MultiThumbnailCreationFilesLoadedEmpty()); + return; + } + + emit(MultiThumbnailCreationThumbnailsLoaded()); + } catch (e) { + if (e is ThumbnailCreationCanceledException) { + logger.d('Thumbnail creation cancelled'); + return; + } + logger.e('Error creating thumbnails: $e'); + + emit(MultiThumbnailCreationError()); + } + + _skippedDrives.clear(); + } + + void _verifyCancelAndEmitLoadingState({ + required MultiThumbnailCreationState state, + required Emitter emit, + }) { + if (this.state is MultiThumbnailCreationCancelled) { + throw ThumbnailCreationCanceledException(); + } + + if (state is MultiThumbnailCreationLoadingThumbnails && + state.driveInExecution != null && + _skippedDrives.contains(state.driveInExecution?.id)) { + return; + } + if (!emit.isDone) emit(state); + } + + @override + Future close() { + _worker?.cancel(); + return super.close(); + } +} + +class ThumbnailCreationCanceledException implements Exception {} diff --git a/lib/drive_explorer/multi_thumbnail_creation/bloc/multi_thumbnail_creation_event.dart b/lib/drive_explorer/multi_thumbnail_creation/bloc/multi_thumbnail_creation_event.dart new file mode 100644 index 0000000000..a0bbe508d8 --- /dev/null +++ b/lib/drive_explorer/multi_thumbnail_creation/bloc/multi_thumbnail_creation_event.dart @@ -0,0 +1,29 @@ +part of 'multi_thumbnail_creation_bloc.dart'; + +sealed class MultiThumbnailCreationEvent extends Equatable { + const MultiThumbnailCreationEvent(); + + @override + List get props => []; +} + +final class CreateMultiThumbnailForAllDrives + extends MultiThumbnailCreationEvent { + const CreateMultiThumbnailForAllDrives(); + + @override + List get props => []; +} + +// cancel +final class CancelMultiThumbnailCreation extends MultiThumbnailCreationEvent {} + +final class SkipDriveMultiThumbnailCreation + extends MultiThumbnailCreationEvent { + const SkipDriveMultiThumbnailCreation(); + + @override + List get props => []; +} + +final class CloseMultiThumbnailCreation extends MultiThumbnailCreationEvent {} diff --git a/lib/drive_explorer/multi_thumbnail_creation/bloc/multi_thumbnail_creation_state.dart b/lib/drive_explorer/multi_thumbnail_creation/bloc/multi_thumbnail_creation_state.dart new file mode 100644 index 0000000000..3759703fb3 --- /dev/null +++ b/lib/drive_explorer/multi_thumbnail_creation/bloc/multi_thumbnail_creation_state.dart @@ -0,0 +1,75 @@ +part of 'multi_thumbnail_creation_bloc.dart'; + +sealed class MultiThumbnailCreationState extends Equatable { + const MultiThumbnailCreationState(); + + @override + List get props => []; +} + +final class MultiThumbnailCreationInitial extends MultiThumbnailCreationState {} + +final class MultiThumbnailCreationLoadingFiles + extends MultiThumbnailCreationState {} + +// final class MultiThumbnailCreationFilesLoaded +// extends MultiThumbnailCreationState { +// final List files; + +// const MultiThumbnailCreationFilesLoaded({required this.files}); + +// @override +// List get props => [files]; +// } + +final class MultiThumbnailCreationLoadingThumbnails + extends MultiThumbnailCreationState { + final List thumbnailsInDrive; + final Drive? driveInExecution; + final int loadedDrives; + final int loadedThumbnailsInDrive; + final int numberOfDrives; + + const MultiThumbnailCreationLoadingThumbnails({ + required this.thumbnailsInDrive, + this.driveInExecution, + required this.loadedDrives, + required this.loadedThumbnailsInDrive, + required this.numberOfDrives, + }); + + @override + List get props => [ + thumbnailsInDrive, + driveInExecution, + loadedDrives, + loadedThumbnailsInDrive, + numberOfDrives, + ]; +} + +final class MultiThumbnailCreationFilesLoadedEmpty + extends MultiThumbnailCreationState {} + +class ThumbnailLoadingStatus { + final FileWithLatestRevisionTransactions file; + final bool loaded; + + const ThumbnailLoadingStatus({ + required this.file, + required this.loaded, + }); +} + +final class MultiThumbnailCreationThumbnailsLoaded + extends MultiThumbnailCreationState {} + +final class MultiThumbnailClosingModal extends MultiThumbnailCreationState {} + +final class MultiThumbnailCreationCancelled + extends MultiThumbnailCreationState {} + +final class MultiThumbnailCreationError extends MultiThumbnailCreationState { + @override + List get props => []; +} diff --git a/lib/drive_explorer/multi_thumbnail_creation/multi_thumbnail_creation_modal.dart b/lib/drive_explorer/multi_thumbnail_creation/multi_thumbnail_creation_modal.dart new file mode 100644 index 0000000000..bb8820bb6e --- /dev/null +++ b/lib/drive_explorer/multi_thumbnail_creation/multi_thumbnail_creation_modal.dart @@ -0,0 +1,369 @@ +import 'package:ardrive/authentication/ardrive_auth.dart'; +import 'package:ardrive/core/arfs/repository/drive_repository.dart'; +import 'package:ardrive/drive_explorer/multi_thumbnail_creation/bloc/multi_thumbnail_creation_bloc.dart'; +import 'package:ardrive/drive_explorer/thumbnail/repository/thumbnail_repository.dart'; +import 'package:ardrive/models/daos/drive_dao/drive_dao.dart'; +import 'package:ardrive/pages/drive_detail/components/hover_widget.dart'; +import 'package:ardrive/utils/app_localizations_wrapper.dart'; +import 'package:ardrive_ui/ardrive_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class MultiThumbnailCreationWrapper extends StatefulWidget { + const MultiThumbnailCreationWrapper({required this.child, super.key}); + + final Widget child; + + @override + State createState() => + _MultiThumbnailCreationWrapperState(); +} + +class _MultiThumbnailCreationWrapperState + extends State { + OverlayEntry? _overlayEntry; + + @override + void initState() { + super.initState(); + } + + void _showOverlay(BuildContext context) { + _overlayEntry = + _createOverlayEntry(context.read()); + Overlay.of(context).insert(_overlayEntry!); + } + + OverlayEntry _createOverlayEntry(MultiThumbnailCreationBloc bloc) { + return OverlayEntry( + builder: (context) => Positioned( + bottom: 0, + right: 20, + child: BlocProvider.value( + value: bloc, + child: MultiThumbnailCreationModalContent( + bloc: bloc, + ), + ), + ), + ); + } + + @override + dispose() { + _overlayEntry?.remove(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Overlay( + initialEntries: [ + OverlayEntry( + builder: (context) => BlocProvider( + create: (context) => MultiThumbnailCreationBloc( + driveRepository: DriveRepository( + driveDao: context.read(), + auth: context.read(), + ), + thumbnailRepository: context.read()), + child: BlocListener( + listener: (context, state) { + if (state is MultiThumbnailClosingModal || + state is MultiThumbnailCreationCancelled) { + _overlayEntry?.remove(); + } + + if (state is MultiThumbnailCreationLoadingFiles) { + _showOverlay(context); + } + }, + child: widget.child, + ), + ), + ) + ], + ); + } +} + +class MultiThumbnailCreationModalContent extends StatefulWidget { + const MultiThumbnailCreationModalContent({required this.bloc, super.key}); + + final MultiThumbnailCreationBloc bloc; + + @override + State createState() => + _MultiThumbnailCreationModalContentState(); +} + +class _MultiThumbnailCreationModalContentState + extends State { + bool isCollapsed = false; + + @override + Widget build(BuildContext context) { + return BlocConsumer( + bloc: widget.bloc, + listener: (context, state) {}, + builder: (context, state) { + final typography = ArDriveTypographyNew.of(context); + + if (state is MultiThumbnailCreationInitial) { + return Container(); + } + + if (state is MultiThumbnailCreationFilesLoadedEmpty) { + return Material( + borderRadius: BorderRadius.circular(modalBorderRadius), + child: ArDriveStandardModalNew( + content: Center( + child: Text('No images missing thumbnails found!', + style: typography.paragraphLarge( + fontWeight: ArFontWeight.semiBold)), + ), + actions: [ + ModalAction( + action: () { + context + .read() + .add(CloseMultiThumbnailCreation()); + }, + title: appLocalizationsOf(context).close, + ) + ], + ), + ); + } + if (state is MultiThumbnailCreationThumbnailsLoaded) { + return Material( + borderRadius: BorderRadius.circular(modalBorderRadius), + child: ArDriveStandardModalNew( + content: Center( + child: Text('Thumbnails created successfully!', + style: typography.paragraphLarge( + fontWeight: ArFontWeight.semiBold)), + ), + actions: [ + ModalAction( + action: () { + context + .read() + .add(CloseMultiThumbnailCreation()); + }, + title: appLocalizationsOf(context).close, + ) + ], + ), + ); + } + + if (state is MultiThumbnailCreationError) { + return Material( + child: ArDriveStandardModalNew( + content: Center( + child: Text('An error occurred while creating thumbnails!', + style: typography.paragraphLarge( + fontWeight: ArFontWeight.semiBold)), + ), + actions: [ + ModalAction( + action: () { + context + .read() + .add(CancelMultiThumbnailCreation()); + }, + title: appLocalizationsOf(context).close, + ) + ], + ), + ); + } + + if (state is MultiThumbnailCreationLoadingFiles) { + return const Center( + child: CircularProgressIndicator(), + ); + } + + if (state is MultiThumbnailCreationLoadingThumbnails) { + final colorTokens = ArDriveTheme.of(context).themeData.colorTokens; + + if (isCollapsed) { + return ArDriveCard( + height: 64, + width: 400, + elevation: 2, + withRedLineOnTop: true, + contentPadding: EdgeInsets.zero, + boxShadow: BoxShadowCard.shadow80, + content: Material( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + children: [ + Text( + '${state.loadedDrives} / ${state.numberOfDrives} drives processed', + style: typography.paragraphLarge( + fontWeight: ArFontWeight.bold)), + const Spacer(), + ArDriveIconButton( + icon: ArDriveIcons.carretUp(), + onPressed: () { + setState(() { + isCollapsed = false; + }); + }, + ), + ArDriveIconButton( + icon: ArDriveIcons.x(), + onPressed: () { + widget.bloc.add(CancelMultiThumbnailCreation()); + }, + ), + ], + ), + ), + ], + ), + ), + ); + } + + return ArDriveCard( + height: 202, + width: 400, + elevation: 2, + contentPadding: EdgeInsets.zero, + boxShadow: BoxShadowCard.shadow80, + content: Material( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + height: 6, + child: Container( + color: colorTokens.containerRed, + ), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.all(8), + child: Column( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceAround, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + if (state.driveInExecution != null) + RichText( + text: TextSpan(children: [ + TextSpan( + text: 'Drive in Execution: ', + style: typography.paragraphLarge( + fontWeight: ArFontWeight.semiBold, + color: colorTokens.textHigh, + ), + ), + TextSpan( + text: state.driveInExecution!.name, + style: typography.paragraphLarge( + fontWeight: ArFontWeight.bold, + color: colorTokens.textHigh, + ), + ), + ]), + ), + const Spacer(), + ArDriveIconButton( + onPressed: () { + widget.bloc.add( + const SkipDriveMultiThumbnailCreation()); + }, + icon: const ArDriveIcon( + icon: Icons.skip_next_outlined), + tooltip: 'Skip drive', + ), + ArDriveIconButton( + icon: ArDriveIcons.carretDown(), + onPressed: () { + setState(() { + isCollapsed = true; + }); + }, + tooltip: 'Collapse', + ), + ], + ), + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Text( + 'Creating thumbnails for images without thumbnails...', + style: typography.paragraphNormal( + fontWeight: ArFontWeight.semiBold, + ), + textAlign: TextAlign.center, + ), + ), + const SizedBox(height: 4), + ArDriveProgressBar( + key: Key(state.driveInExecution?.id.toString() ?? + 'driveInExecution'), + height: 10, + percentage: state.loadedThumbnailsInDrive / + state.thumbnailsInDrive.length, + indicatorColor: colorTokens.containerRed, + ), + Align( + alignment: Alignment.centerRight, + child: Text( + '${state.loadedThumbnailsInDrive} / ${state.thumbnailsInDrive.length}', + style: typography.paragraphLarge( + fontWeight: ArFontWeight.semiBold, + ), + ), + ), + const SizedBox( + height: 4, + ), + Text( + 'Drives processed: ${state.loadedDrives} of ${state.numberOfDrives}', + style: typography.paragraphLarge( + fontWeight: ArFontWeight.semiBold), + ), + ], + ), + ), + ), + const Divider(), + Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: ArDriveClickArea( + child: GestureDetector( + onTap: () { + widget.bloc.add(CancelMultiThumbnailCreation()); + }, + child: Text( + 'Cancel', + style: typography.paragraphNormal(), + ), + ), + ), + ), + ], + ), + ), + ); + } + + return Container(); + }, + ); + } +} diff --git a/lib/drive_explorer/thumbnail/repository/thumbnail_repository.dart b/lib/drive_explorer/thumbnail/repository/thumbnail_repository.dart index 9ca66542d0..06d9fd4d05 100644 --- a/lib/drive_explorer/thumbnail/repository/thumbnail_repository.dart +++ b/lib/drive_explorer/thumbnail/repository/thumbnail_repository.dart @@ -17,6 +17,7 @@ import 'package:ardrive_utils/ardrive_utils.dart'; import 'package:cryptography/cryptography.dart'; import 'package:drift/drift.dart' as drift; +// TODO(@thiagocarvalhodev): implement unit tests class ThumbnailRepository { final ArweaveService _arweaveService; final ArDriveDownloader _arDriveDownloader; @@ -38,24 +39,35 @@ class ThumbnailRepository { _arweaveService = arweaveService, _turboUploadService = turboUploadService, _arDriveAuth = arDriveAuth; + final Map _cachedThumbnails = {}; Future getThumbnail({ - FileDataTableItem? fileDataTableItem, + required FileDataTableItem fileDataTableItem, }) async { + if (_cachedThumbnails[fileDataTableItem.id] != null) { + return _cachedThumbnails[fileDataTableItem.id]!; + } + final drive = await _driveDao - .driveById(driveId: fileDataTableItem!.driveId) + .driveById(driveId: fileDataTableItem.driveId) .getSingle(); if (drive.isPrivate) { - return ThumbnailData( - data: await _getThumbnailData(fileDataTableItem: fileDataTableItem), - url: null); + _cachedThumbnails[fileDataTableItem.id] = ThumbnailData( + data: await _getThumbnailData(fileDataTableItem: fileDataTableItem), + url: null, + ); + + return _cachedThumbnails[fileDataTableItem.id]!; } final urlString = '${_arweaveService.client.api.gatewayUrl.origin}/raw/${fileDataTableItem.thumbnail?.variants.first.txId}'; - return ThumbnailData(data: null, url: urlString); + _cachedThumbnails[fileDataTableItem.id] = + ThumbnailData(data: null, url: urlString); + + return _cachedThumbnails[fileDataTableItem.id]!; } Future _getThumbnailData({ @@ -91,7 +103,7 @@ class ThumbnailRepository { var fileEntry = await (_driveDao.select(_driveDao.fileEntries) ..where((tbl) => tbl.id.equals(fileId))) .getSingle(); - // get image + final dataTx = await _arweaveService.getTransactionDetails(fileEntry.dataTxId); diff --git a/lib/drive_explorer/thumbnail_creation/page/thumbnail_creation_modal.dart b/lib/drive_explorer/thumbnail_creation/page/thumbnail_creation_modal.dart index 1dda8c6dd2..8e7f3b0c7f 100644 --- a/lib/drive_explorer/thumbnail_creation/page/thumbnail_creation_modal.dart +++ b/lib/drive_explorer/thumbnail_creation/page/thumbnail_creation_modal.dart @@ -1,14 +1,8 @@ -import 'package:ardrive/authentication/ardrive_auth.dart'; import 'package:ardrive/blocs/blocs.dart'; -import 'package:ardrive/download/ardrive_downloader.dart'; import 'package:ardrive/drive_explorer/thumbnail/repository/thumbnail_repository.dart'; import 'package:ardrive/drive_explorer/thumbnail_creation/bloc/thumbnail_creation_bloc.dart'; -import 'package:ardrive/models/daos/drive_dao/drive_dao.dart'; import 'package:ardrive/pages/drive_detail/drive_detail_page.dart'; -import 'package:ardrive/services/arweave/arweave_service.dart'; -import 'package:ardrive/turbo/services/upload_service.dart'; import 'package:ardrive_ui/ardrive_ui.dart'; -import 'package:ardrive_uploader/ardrive_uploader.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -20,14 +14,7 @@ class ThumbnailCreationModal extends StatelessWidget { @override Widget build(BuildContext context) { return RepositoryProvider( - create: (context) => ThumbnailRepository( - arDriveAuth: context.read(), - arDriveDownloader: context.read(), - arDriveUploader: context.read(), - arweaveService: context.read(), - driveDao: context.read(), - turboUploadService: context.read(), - ), + create: (context) => context.read(), child: BlocProvider( create: (context) { return ThumbnailCreationBloc( diff --git a/lib/main.dart b/lib/main.dart index 4dde471a3c..d599c33bb7 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -15,9 +15,11 @@ import 'package:ardrive/core/crypto/crypto.dart'; import 'package:ardrive/core/upload/cost_calculator.dart'; import 'package:ardrive/core/upload/uploader.dart'; import 'package:ardrive/download/ardrive_downloader.dart'; +import 'package:ardrive/drive_explorer/thumbnail/repository/thumbnail_repository.dart'; import 'package:ardrive/models/database/database_helpers.dart'; import 'package:ardrive/services/authentication/biometric_authentication.dart'; import 'package:ardrive/services/config/config_fetcher.dart'; +import 'package:ardrive/shared/blocs/banner/app_banner_bloc.dart'; import 'package:ardrive/sharing/blocs/sharing_file_bloc.dart'; import 'package:ardrive/sync/domain/repositories/sync_repository.dart'; import 'package:ardrive/sync/utils/batch_processor.dart'; @@ -336,6 +338,7 @@ class AppState extends State { context.read(), ), ), + BlocProvider(create: (context) => AppBannerBloc()), ]; List get repositoryProviders => [ @@ -454,5 +457,22 @@ class AppState extends State { pstService: _.read(), ), ), + RepositoryProvider( + create: (context) => ThumbnailRepository( + arDriveDownloader: ArDriveDownloader( + arweave: context.read(), + ardriveIo: ArDriveIO(), + ioFileAdapter: IOFileAdapter(), + ), + driveDao: context.read(), + arweaveService: context.read(), + arDriveAuth: context.read(), + arDriveUploader: ArDriveUploader( + turboUploadUri: Uri.parse( + context.read().config.defaultTurboUploadUrl!), + ), + turboUploadService: context.read(), + ), + ) ]; } diff --git a/lib/pages/app_router_delegate.dart b/lib/pages/app_router_delegate.dart index c100cf00be..a903233cf1 100644 --- a/lib/pages/app_router_delegate.dart +++ b/lib/pages/app_router_delegate.dart @@ -11,6 +11,7 @@ import 'package:ardrive/components/feedback_survey.dart'; import 'package:ardrive/core/activity_tracker.dart'; import 'package:ardrive/core/arfs/repository/folder_repository.dart'; import 'package:ardrive/dev_tools/app_dev_tools.dart'; +import 'package:ardrive/drive_explorer/multi_thumbnail_creation/multi_thumbnail_creation_modal.dart'; import 'package:ardrive/entities/constants.dart'; import 'package:ardrive/models/models.dart'; import 'package:ardrive/pages/pages.dart'; @@ -80,259 +81,267 @@ class AppRouterDelegate extends RouterDelegate } Widget _app() { - return BlocConsumer( - listener: (context, state) { - if (state is ThemeSwitcherDarkTheme) { - ArDriveUIThemeSwitcher.changeTheme(ArDriveThemes.dark); - } else if (state is ThemeSwitcherLightTheme) { - ArDriveUIThemeSwitcher.changeTheme(ArDriveThemes.light); - } - }, - builder: (context, _) => BlocConsumer( + return MultiThumbnailCreationWrapper( + child: BlocConsumer( listener: (context, state) { - // Clear state to prevent the last drive from being attached on new - // login. - if (state is ProfileLoggingOut) { - logger.d('Logging out. Clearing state.'); - - clearState(); - } - - final anonymouslyShowDriveDetail = - state is! ProfileLoggedIn && canAnonymouslyShowDriveDetail(state); - - // If the user is not already signing in, not viewing a shared file - // and not anonymously viewing a drive, redirect them to sign in. - // - // Additionally, redirect the user to sign in if they are logging out. - final showingAnonymousRoute = - anonymouslyShowDriveDetail || isViewingSharedFile; - - if (!signingIn && - !gettingStarted && - (!showingAnonymousRoute || state is ProfileLoggingOut)) { - signingIn = true; - gettingStarted = false; - notifyListeners(); - } - - // Redirect the user away from sign in if they are already signed in. - if ((signingIn || gettingStarted) && state is ProfileLoggedIn) { - signingIn = false; - gettingStarted = false; - notifyListeners(); - } - - // Cleans up any shared drives from previous sessions - // TODO: Find a better place to do this - final lastLoggedInUser = - state is ProfileLoggedIn ? state.walletAddress : null; - if (lastLoggedInUser != null) { - context - .read() - .deleteSharedPrivateDrives(lastLoggedInUser); + if (state is ThemeSwitcherDarkTheme) { + ArDriveUIThemeSwitcher.changeTheme(ArDriveThemes.dark); + } else if (state is ThemeSwitcherLightTheme) { + ArDriveUIThemeSwitcher.changeTheme(ArDriveThemes.light); } }, - builder: (context, state) { - Widget? shell; - - final anonymouslyShowDriveDetail = - canAnonymouslyShowDriveDetail(state); - if (isViewingSharedFile) { - shell = BlocProvider( - key: ValueKey(sharedFileId), - create: (_) => SharedFileCubit( - fileId: sharedFileId!, - fileKey: sharedFileKey, - arweave: context.read(), - licenseService: context.read(), - ), - child: SharedFilePage(), - ); - } else if (signingIn) { - shell = const LoginPage(); - } else if (gettingStarted) { - shell = const LoginPage(gettingStarted: true); - } else if (state is ProfileLoggedIn || anonymouslyShowDriveDetail) { - shell = BlocConsumer( - listener: (context, state) { - if (state is DrivesLoadSuccess) { - final selectedDriveChanged = driveId != state.selectedDriveId; - if (selectedDriveChanged) { - driveFolderId = null; - } + builder: (context, _) => BlocConsumer( + listener: (context, state) { + // Clear state to prevent the last drive from being attached on new + // login. + if (state is ProfileLoggingOut) { + logger.d('Logging out. Clearing state.'); + + clearState(); + } + + final anonymouslyShowDriveDetail = state is! ProfileLoggedIn && + canAnonymouslyShowDriveDetail(state); + + // If the user is not already signing in, not viewing a shared file + // and not anonymously viewing a drive, redirect them to sign in. + // + // Additionally, redirect the user to sign in if they are logging out. + final showingAnonymousRoute = + anonymouslyShowDriveDetail || isViewingSharedFile; + + if (!signingIn && + !gettingStarted && + (!showingAnonymousRoute || state is ProfileLoggingOut)) { + signingIn = true; + gettingStarted = false; + notifyListeners(); + } - driveId = state.selectedDriveId; - notifyListeners(); - } - }, - builder: (context, state) { - Widget? shellPage; - if (state is DrivesLoadSuccess) { - shellPage = !state.hasNoDrives - ? DriveDetailPage( - context: navigatorKey.currentContext!, - anonymouslyShowDriveDetail: - anonymouslyShowDriveDetail, - ) - : NoDrivesPage( - anonymouslyShowDriveDetail: - anonymouslyShowDriveDetail, - ); - - driveId = state.selectedDriveId; - } + // Redirect the user away from sign in if they are already signed in. + if ((signingIn || gettingStarted) && state is ProfileLoggedIn) { + signingIn = false; + gettingStarted = false; + notifyListeners(); + } + + // Cleans up any shared drives from previous sessions + // TODO: Find a better place to do this + final lastLoggedInUser = + state is ProfileLoggedIn ? state.walletAddress : null; + if (lastLoggedInUser != null) { + context + .read() + .deleteSharedPrivateDrives(lastLoggedInUser); + } + }, + builder: (context, state) { + Widget? shell; + + final anonymouslyShowDriveDetail = + canAnonymouslyShowDriveDetail(state); + if (isViewingSharedFile) { + shell = BlocProvider( + key: ValueKey(sharedFileId), + create: (_) => SharedFileCubit( + fileId: sharedFileId!, + fileKey: sharedFileKey, + arweave: context.read(), + licenseService: context.read(), + ), + child: SharedFilePage(), + ); + } else if (signingIn) { + shell = const LoginPage(); + } else if (gettingStarted) { + shell = const LoginPage(gettingStarted: true); + } else if (state is ProfileLoggedIn || anonymouslyShowDriveDetail) { + shell = BlocConsumer( + listener: (context, state) { + if (state is DrivesLoadSuccess) { + final selectedDriveChanged = + driveId != state.selectedDriveId; + if (selectedDriveChanged) { + driveFolderId = null; + } + + driveId = state.selectedDriveId; + notifyListeners(); + } + }, + builder: (context, state) { + Widget? shellPage; + if (state is DrivesLoadSuccess) { + shellPage = !state.hasNoDrives + ? DriveDetailPage( + context: navigatorKey.currentContext!, + anonymouslyShowDriveDetail: + anonymouslyShowDriveDetail, + ) + : NoDrivesPage( + anonymouslyShowDriveDetail: + anonymouslyShowDriveDetail, + ); + + driveId = state.selectedDriveId; + } - shellPage ??= const SizedBox(); - driveId = driveId ?? rootPath; - - return BlocProvider( - key: ValueKey(driveId), - create: (context) => DriveDetailCubit( - activityTracker: context.read(), - driveId: driveId!, - initialFolderId: driveFolderId, - profileCubit: context.read(), - driveDao: context.read(), - configService: context.read(), - auth: context.read(), - breadcrumbBuilder: BreadcrumbBuilder( - context.read(), + shellPage ??= const SizedBox(); + driveId = driveId ?? rootPath; + + return BlocProvider( + key: ValueKey(driveId), + create: (context) => DriveDetailCubit( + activityTracker: context.read(), + driveId: driveId!, + initialFolderId: driveFolderId, + profileCubit: context.read(), + driveDao: context.read(), + configService: context.read(), + auth: context.read(), + breadcrumbBuilder: BreadcrumbBuilder( + context.read(), + ), ), - ), - child: MultiBlocListener( - listeners: [ - BlocListener( - listener: (context, driveDetailCubitState) { - if (driveDetailCubitState is DriveDetailLoadSuccess) { - driveId = driveDetailCubitState.currentDrive.id; - driveFolderId = - driveDetailCubitState.folderInView.folder.id; - - //Can be null at the root folder of the drive - notifyListeners(); - } else if (driveDetailCubitState - is DriveDetailLoadNotFound) { - // Do not prompt the user to attach an unfound drive if they are logging out. - final profileCubit = context.read(); - - if (profileCubit.state is ProfileLoggingOut) { - logger.d( - 'Drive not found, but user is logging out. Not prompting to attach drive.'); - - clearState(); - - return; + child: MultiBlocListener( + listeners: [ + BlocListener( + listener: (context, driveDetailCubitState) { + if (driveDetailCubitState + is DriveDetailLoadSuccess) { + driveId = driveDetailCubitState.currentDrive.id; + driveFolderId = + driveDetailCubitState.folderInView.folder.id; + + //Can be null at the root folder of the drive + notifyListeners(); + } else if (driveDetailCubitState + is DriveDetailLoadNotFound) { + // Do not prompt the user to attach an unfound drive if they are logging out. + final profileCubit = context.read(); + + if (profileCubit.state is ProfileLoggingOut) { + logger.d( + 'Drive not found, but user is logging out. Not prompting to attach drive.'); + + clearState(); + + return; + } + + attachDrive( + context: context, + driveId: driveId, + driveName: driveName, + driveKey: sharedDriveKey, + ); } - - attachDrive( - context: context, - driveId: driveId, - driveName: driveName, - driveKey: sharedDriveKey, - ); - } - }, - ), - BlocListener( - listener: (context, state) { - if (state is FeedbackSurveyRemindMe && state.isOpen) { - openFeedbackSurveyModal(context); - } else if (state is FeedbackSurveyRemindMe && - !state.isOpen) { - Navigator.pop(context); - } else if (state is FeedbackSurveyDontRemindMe && - !state.isOpen) { - Navigator.pop(context); - } - }, - ), - BlocListener( - listener: ((context, state) { - if (state is ProfileLoggingOut) { - context.read().reset(); - } - }), + }, + ), + BlocListener( + listener: (context, state) { + if (state is FeedbackSurveyRemindMe && + state.isOpen) { + openFeedbackSurveyModal(context); + } else if (state is FeedbackSurveyRemindMe && + !state.isOpen) { + Navigator.pop(context); + } else if (state is FeedbackSurveyDontRemindMe && + !state.isOpen) { + Navigator.pop(context); + } + }, + ), + BlocListener( + listener: ((context, state) { + if (state is ProfileLoggingOut) { + context.read().reset(); + } + }), + ), + ], + child: AppShell( + page: shellPage, ), - ], - child: AppShell( - page: shellPage, ), - ), - ); + ); + }, + ); + } + + shell ??= const SizedBox(); + + final navigator = Navigator( + key: navigatorKey, + pages: [ + MaterialPage( + key: const ValueKey('AppShell'), + child: shell, + ), + ], + onPopPage: (route, result) { + if (!route.didPop(result)) { + return false; + } + + notifyListeners(); + return true; }, ); - } - shell ??= const SizedBox(); - - final navigator = Navigator( - key: navigatorKey, - pages: [ - MaterialPage( - key: const ValueKey('AppShell'), - child: shell, - ), - ], - onPopPage: (route, result) { - if (!route.didPop(result)) { - return false; - } - - notifyListeners(); - return true; - }, - ); - - if (state is ProfileLoggedIn || anonymouslyShowDriveDetail) { - return MultiBlocProvider( - providers: [ - BlocProvider( - create: (context) => SyncCubit( - syncRepository: context.read(), - activityTracker: context.read(), - configService: context.read(), - profileCubit: context.read(), - activityCubit: context.read(), - promptToSnapshotBloc: context.read(), - tabVisibility: TabVisibilitySingleton(), + if (state is ProfileLoggedIn || anonymouslyShowDriveDetail) { + return MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) => SyncCubit( + syncRepository: context.read(), + activityTracker: context.read(), + configService: context.read(), + profileCubit: context.read(), + activityCubit: context.read(), + promptToSnapshotBloc: + context.read(), + tabVisibility: TabVisibilitySingleton(), + ), ), - ), - BlocProvider( - create: (context) => DrivesCubit( - activityTracker: context.read(), - auth: context.read(), - initialSelectedDriveId: driveId, - profileCubit: context.read(), - driveDao: context.read(), - promptToSnapshotBloc: context.read(), + BlocProvider( + create: (context) => DrivesCubit( + activityTracker: context.read(), + auth: context.read(), + initialSelectedDriveId: driveId, + profileCubit: context.read(), + driveDao: context.read(), + promptToSnapshotBloc: + context.read(), + ), ), - ), - ], - child: BlocListener( - listener: (context, state) { - if (state is SyncFailure) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - appLocalizationsOf(context).failedToSyncDrive, - ), - action: SnackBarAction( - label: appLocalizationsOf(context).tryAgainEmphasized, - onPressed: () => - context.read().startSync(), + ], + child: BlocListener( + listener: (context, state) { + if (state is SyncFailure) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + appLocalizationsOf(context).failedToSyncDrive, + ), + action: SnackBarAction( + label: + appLocalizationsOf(context).tryAgainEmphasized, + onPressed: () => + context.read().startSync(), + ), ), - ), - ); - } - }, - child: navigator, - ), - ); - } else { - return navigator; - } - }, + ); + } + }, + child: navigator, + ), + ); + } else { + return navigator; + } + }, + ), ), ); } diff --git a/lib/pages/drive_detail/components/drive_explorer_item_tile.dart b/lib/pages/drive_detail/components/drive_explorer_item_tile.dart index 19483c5a2f..cdc9572677 100644 --- a/lib/pages/drive_detail/components/drive_explorer_item_tile.dart +++ b/lib/pages/drive_detail/components/drive_explorer_item_tile.dart @@ -1,4 +1,3 @@ -import 'package:ardrive/authentication/ardrive_auth.dart'; import 'package:ardrive/blocs/drive_detail/drive_detail_cubit.dart'; import 'package:ardrive/components/components.dart'; import 'package:ardrive/components/csv_export_dialog.dart'; @@ -7,7 +6,6 @@ import 'package:ardrive/components/fs_entry_license_form.dart'; import 'package:ardrive/components/ghost_fixer_form.dart'; import 'package:ardrive/components/hide_dialog.dart'; import 'package:ardrive/components/pin_indicator.dart'; -import 'package:ardrive/download/ardrive_downloader.dart'; import 'package:ardrive/download/multiple_file_download_modal.dart'; import 'package:ardrive/drive_explorer/thumbnail/repository/thumbnail_repository.dart'; import 'package:ardrive/drive_explorer/thumbnail/thumbnail_bloc.dart'; @@ -16,15 +14,10 @@ import 'package:ardrive/pages/congestion_warning_wrapper.dart'; import 'package:ardrive/pages/drive_detail/components/dropdown_item.dart'; import 'package:ardrive/pages/drive_detail/components/hover_widget.dart'; import 'package:ardrive/pages/drive_detail/drive_detail_page.dart'; -import 'package:ardrive/services/arweave/arweave.dart'; -import 'package:ardrive/services/config/config.dart'; -import 'package:ardrive/turbo/services/upload_service.dart'; import 'package:ardrive/utils/app_localizations_wrapper.dart'; import 'package:ardrive/utils/logger.dart'; import 'package:ardrive/utils/size_constants.dart'; -import 'package:ardrive_io/ardrive_io.dart'; import 'package:ardrive_ui/ardrive_ui.dart'; -import 'package:ardrive_uploader/ardrive_uploader.dart'; import 'package:ardrive_utils/ardrive_utils.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -103,24 +96,8 @@ class DriveExplorerItemTileLeading extends StatelessWidget { children: [ BlocProvider( create: (context) => ThumbnailBloc( - thumbnailRepository: ThumbnailRepository( - arDriveDownloader: ArDriveDownloader( - arweave: context.read(), - ardriveIo: ArDriveIO(), - ioFileAdapter: IOFileAdapter(), - ), - driveDao: context.read(), - arweaveService: context.read(), - arDriveAuth: context.read(), - arDriveUploader: ArDriveUploader( - turboUploadUri: Uri.parse(context - .read() - .config - .defaultTurboUploadUrl!), - ), - turboUploadService: context.read(), - ), - )..add( + thumbnailRepository: context.read()) + ..add( GetThumbnail(fileDataTableItem: file), ), child: BlocBuilder( diff --git a/lib/pages/drive_detail/drive_detail_page.dart b/lib/pages/drive_detail/drive_detail_page.dart index 7115892b5b..61cc1db0f9 100644 --- a/lib/pages/drive_detail/drive_detail_page.dart +++ b/lib/pages/drive_detail/drive_detail_page.dart @@ -27,9 +27,7 @@ import 'package:ardrive/components/side_bar.dart'; import 'package:ardrive/core/activity_tracker.dart'; import 'package:ardrive/dev_tools/app_dev_tools.dart'; import 'package:ardrive/dev_tools/shortcut_handler.dart'; -import 'package:ardrive/download/ardrive_downloader.dart'; import 'package:ardrive/download/multiple_file_download_modal.dart'; -import 'package:ardrive/drive_explorer/thumbnail/repository/thumbnail_repository.dart'; import 'package:ardrive/entities/entities.dart' as entities; import 'package:ardrive/entities/file_entity.dart'; import 'package:ardrive/l11n/l11n.dart'; @@ -50,7 +48,6 @@ import 'package:ardrive/shared/components/plausible_page_view_wrapper.dart'; import 'package:ardrive/sharing/sharing_file_listener.dart'; import 'package:ardrive/sync/domain/cubit/sync_cubit.dart'; import 'package:ardrive/theme/theme.dart'; -import 'package:ardrive/turbo/services/upload_service.dart'; import 'package:ardrive/utils/app_localizations_wrapper.dart'; import 'package:ardrive/utils/compare_alphabetically_and_natural.dart'; import 'package:ardrive/utils/filesize.dart'; @@ -62,7 +59,6 @@ import 'package:ardrive/utils/size_constants.dart'; import 'package:ardrive/utils/user_utils.dart'; import 'package:ardrive_io/ardrive_io.dart'; import 'package:ardrive_ui/ardrive_ui.dart'; -import 'package:ardrive_uploader/ardrive_uploader.dart'; import 'package:ardrive_utils/ardrive_utils.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -156,48 +152,46 @@ class _DriveDetailPageState extends State { } } }, - child: RepositoryProvider( - create: (context) => ThumbnailRepository( - arDriveDownloader: ArDriveDownloader( - arweave: context.read(), - ardriveIo: ArDriveIO(), - ioFileAdapter: IOFileAdapter(), - ), - driveDao: context.read(), - arweaveService: context.read(), - arDriveAuth: context.read(), - arDriveUploader: ArDriveUploader( - turboUploadUri: Uri.parse(context - .read() - .config - .defaultTurboUploadUrl!), - ), - turboUploadService: context.read(), - ), - child: BlocBuilder( - builder: (context, driveDetailState) { - if (driveDetailState is DriveDetailLoadInProgress) { - return const Center(child: CircularProgressIndicator()); - } else if (driveDetailState is DriveInitialLoading) { - return ArDriveDevToolsShortcuts( - customShortcuts: [ - Shortcut( - modifier: LogicalKeyboardKey.shiftLeft, - key: LogicalKeyboardKey.keyH, - action: () { - ArDriveDevTools.instance - .showDevTools(optionalContext: context); - }, - ), - ], - child: ScreenTypeLayout.builder( - mobile: (context) { - return Scaffold( - drawerScrimColor: Colors.transparent, - drawer: const AppSideBar(), - appBar: const MobileAppBar(), - body: Padding( - padding: const EdgeInsets.all(8.0), + child: BlocBuilder( + builder: (context, driveDetailState) { + if (driveDetailState is DriveDetailLoadInProgress) { + return const Center(child: CircularProgressIndicator()); + } else if (driveDetailState is DriveInitialLoading) { + return ArDriveDevToolsShortcuts( + customShortcuts: [ + Shortcut( + modifier: LogicalKeyboardKey.shiftLeft, + key: LogicalKeyboardKey.keyH, + action: () { + ArDriveDevTools.instance + .showDevTools(optionalContext: context); + }, + ), + ], + child: ScreenTypeLayout.builder( + mobile: (context) { + return Scaffold( + drawerScrimColor: Colors.transparent, + drawer: const AppSideBar(), + appBar: const MobileAppBar(), + body: Padding( + padding: const EdgeInsets.all(8.0), + child: Center( + child: Text( + appLocalizationsOf(context) + .driveDoingInitialSetupMessage, + style: ArDriveTypography.body.buttonLargeBold(), + ), + ), + ), + ); + }, + desktop: (context) => Scaffold( + drawerScrimColor: Colors.transparent, + body: Column( + children: [ + const AppTopBar(), + Expanded( child: Center( child: Text( appLocalizationsOf(context) @@ -206,106 +200,87 @@ class _DriveDetailPageState extends State { ), ), ), - ); - }, - desktop: (context) => Scaffold( - drawerScrimColor: Colors.transparent, - body: Column( - children: [ - const AppTopBar(), - Expanded( - child: Center( - child: Text( - appLocalizationsOf(context) - .driveDoingInitialSetupMessage, - style: - ArDriveTypography.body.buttonLargeBold(), - ), - ), - ), - ], - ), + ], ), ), - ); - } else if (driveDetailState is DriveDetailLoadSuccess) { - final isShowingHiddenFiles = - driveDetailState.isShowingHiddenFiles; - final bool hasSubfolders; - final bool hasFiles; + ), + ); + } else if (driveDetailState is DriveDetailLoadSuccess) { + final isShowingHiddenFiles = + driveDetailState.isShowingHiddenFiles; + final bool hasSubfolders; + final bool hasFiles; - if (isShowingHiddenFiles) { - hasSubfolders = - driveDetailState.folderInView.subfolders.isNotEmpty; - hasFiles = driveDetailState.folderInView.files.isNotEmpty; - } else { - hasSubfolders = driveDetailState.folderInView.subfolders - .where((e) => !e.isHidden) - .isNotEmpty; - hasFiles = driveDetailState.folderInView.files - .where((e) => !e.isHidden) - .isNotEmpty; - } + if (isShowingHiddenFiles) { + hasSubfolders = + driveDetailState.folderInView.subfolders.isNotEmpty; + hasFiles = driveDetailState.folderInView.files.isNotEmpty; + } else { + hasSubfolders = driveDetailState.folderInView.subfolders + .where((e) => !e.isHidden) + .isNotEmpty; + hasFiles = driveDetailState.folderInView.files + .where((e) => !e.isHidden) + .isNotEmpty; + } - final isOwner = isDriveOwner( - context.read(), - driveDetailState.currentDrive.ownerAddress, - ); + final isOwner = isDriveOwner( + context.read(), + driveDetailState.currentDrive.ownerAddress, + ); - final canDownloadMultipleFiles = driveDetailState - .multiselect && - context.read().selectedItems.isNotEmpty; + final canDownloadMultipleFiles = driveDetailState.multiselect && + context.read().selectedItems.isNotEmpty; - return ArDriveDevToolsShortcuts( - customShortcuts: [ - Shortcut( - modifier: LogicalKeyboardKey.shiftLeft, - key: LogicalKeyboardKey.keyH, - action: () { - ArDriveDevTools.instance - .showDevTools(optionalContext: context); - }, - ), - ], - child: ScreenTypeLayout.builder( - desktop: (context) => _desktopView( - isDriveOwner: isOwner, - driveDetailState: driveDetailState, - hasSubfolders: hasSubfolders, - hasFiles: hasFiles, - canDownloadMultipleFiles: canDownloadMultipleFiles, - ), - mobile: (context) => Scaffold( - resizeToAvoidBottomInset: false, - drawerScrimColor: Colors.transparent, - drawer: const AppSideBar(), - appBar: (driveDetailState.showSelectedItemDetails && - context.read().selectedItem != - null) - ? MobileAppBar( - leading: ArDriveIconButton( - icon: ArDriveIcons.arrowLeft(), - onPressed: () { - context - .read() - .toggleSelectedItemDetails(); - }, - ), - ) - : null, - body: _mobileView( - driveDetailState, - hasSubfolders, - hasFiles, - ), + return ArDriveDevToolsShortcuts( + customShortcuts: [ + Shortcut( + modifier: LogicalKeyboardKey.shiftLeft, + key: LogicalKeyboardKey.keyH, + action: () { + ArDriveDevTools.instance + .showDevTools(optionalContext: context); + }, + ), + ], + child: ScreenTypeLayout.builder( + desktop: (context) => _desktopView( + isDriveOwner: isOwner, + driveDetailState: driveDetailState, + hasSubfolders: hasSubfolders, + hasFiles: hasFiles, + canDownloadMultipleFiles: canDownloadMultipleFiles, + ), + mobile: (context) => Scaffold( + resizeToAvoidBottomInset: false, + drawerScrimColor: Colors.transparent, + drawer: const AppSideBar(), + appBar: (driveDetailState.showSelectedItemDetails && + context.read().selectedItem != + null) + ? MobileAppBar( + leading: ArDriveIconButton( + icon: ArDriveIcons.arrowLeft(), + onPressed: () { + context + .read() + .toggleSelectedItemDetails(); + }, + ), + ) + : null, + body: _mobileView( + driveDetailState, + hasSubfolders, + hasFiles, ), ), - ); - } else { - return const SizedBox(); - } - }, - ), + ), + ); + } else { + return const SizedBox(); + } + }, ), ), ), @@ -320,6 +295,7 @@ class _DriveDetailPageState extends State { required bool canDownloadMultipleFiles, }) { final driveDetailCubit = context.read(); + ArDriveTypographyNew.of(context); final isShowingHiddenFiles = driveDetailState.isShowingHiddenFiles; diff --git a/lib/shared/blocs/banner/app_banner_bloc.dart b/lib/shared/blocs/banner/app_banner_bloc.dart new file mode 100644 index 0000000000..2ebbbd36c9 --- /dev/null +++ b/lib/shared/blocs/banner/app_banner_bloc.dart @@ -0,0 +1,15 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +part 'app_banner_event.dart'; +part 'app_banner_state.dart'; + +class AppBannerBloc extends Bloc { + AppBannerBloc() : super(AppBannerVisible()) { + on((event, emit) { + if (event is AppBannerCloseEvent) { + emit(AppBannerHidden()); + } + }); + } +} diff --git a/lib/shared/blocs/banner/app_banner_event.dart b/lib/shared/blocs/banner/app_banner_event.dart new file mode 100644 index 0000000000..1b5104cabb --- /dev/null +++ b/lib/shared/blocs/banner/app_banner_event.dart @@ -0,0 +1,12 @@ +part of 'app_banner_bloc.dart'; + +sealed class AppBannerEvent extends Equatable { + const AppBannerEvent(); + + @override + List get props => []; +} + +final class AppBannerCloseEvent extends AppBannerEvent { + const AppBannerCloseEvent(); +} diff --git a/lib/shared/blocs/banner/app_banner_state.dart b/lib/shared/blocs/banner/app_banner_state.dart new file mode 100644 index 0000000000..8d984498d6 --- /dev/null +++ b/lib/shared/blocs/banner/app_banner_state.dart @@ -0,0 +1,12 @@ +part of 'app_banner_bloc.dart'; + +sealed class AppBannerState extends Equatable { + const AppBannerState(); + + @override + List get props => []; +} + +final class AppBannerVisible extends AppBannerState {} + +final class AppBannerHidden extends AppBannerState {} diff --git a/packages/ardrive_ui/assets/fonts/ArDriveIcons.ttf b/packages/ardrive_ui/assets/fonts/ArDriveIcons.ttf index 92537596c2..bf42c5c465 100644 Binary files a/packages/ardrive_ui/assets/fonts/ArDriveIcons.ttf and b/packages/ardrive_ui/assets/fonts/ArDriveIcons.ttf differ diff --git a/packages/ardrive_ui/assets/fonts/config.json b/packages/ardrive_ui/assets/fonts/config.json index e97d321cfa..5a15694fa4 100644 --- a/packages/ardrive_ui/assets/fonts/config.json +++ b/packages/ardrive_ui/assets/fonts/config.json @@ -1013,6 +1013,20 @@ "search": [ "public-drive" ] + }, + { + "uid": "058c3624e66ccfbc8c3ee1ba08139743", + "css": "asc", + "code": 59454, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M393 94.2H554.4C608.2 94.2 651 94.2 685.5 97.1 720.8 99.9 751 106 778.6 120.1 823.1 142.8 859.4 179 882.1 223.6 906.8 272.1 907.7 332.1 907.9 420.7 907.9 429.9 907.9 479 907.9 489 907.9 510.8 890.2 528.4 868.4 528.4 846.6 528.4 828.9 510.8 828.9 489 828.9 483.9 828.9 468.6 828.9 453.7 809.6 450.9 789.9 449.5 769.7 449.5 560.9 449.5 388.2 603.9 359.5 804.7 370.5 804.8 382.2 804.8 394.7 804.8H513.2C535 804.8 552.6 822.4 552.6 844.2 552.6 866 535 883.7 513.2 883.7H394.4C364.7 883.7 338.5 883.7 315.5 883.3 254.6 882.1 208.5 878.1 168.8 857.9 124.2 835.2 88 799 65.3 754.4 51.2 726.8 45.2 696.6 42.3 661.3 39.5 626.8 39.5 584 39.5 530.2V447.8C39.5 394 39.5 351.2 42.3 316.7 45.2 281.3 51.2 251.2 65.3 223.6 88 179 124.2 142.8 168.8 120.1 196.4 106 226.5 99.9 261.9 97.1 296.4 94.2 339.2 94.2 393 94.2ZM280 803C310.1 559.3 517.9 370.6 769.7 370.6 789.6 370.6 809.2 371.7 828.5 374 827.4 312.2 823.5 282.4 811.7 259.4 796.6 229.7 772.4 205.5 742.7 190.4 728.1 182.9 709.5 178.2 679 175.7 648.1 173.2 608.5 173.2 552.6 173.2H394.7C338.8 173.2 299.3 173.2 268.3 175.7 237.8 178.2 219.3 182.9 204.6 190.4 174.9 205.5 150.8 229.7 135.6 259.4 128.2 274 123.5 292.6 121 323.1 118.5 354 118.4 393.6 118.4 449.5V528.4C118.4 584.4 118.5 623.9 121 654.9 123.5 685.3 128.2 703.9 135.6 718.6 150.8 748.3 174.9 772.4 204.6 787.6 221.5 796.2 242.9 800.8 280 803ZM197.4 350.8C197.4 296.3 241.6 252.1 296.1 252.1 350.6 252.1 394.7 296.3 394.7 350.8 394.7 405.3 350.6 449.5 296.1 449.5 241.6 449.5 197.4 405.3 197.4 350.8ZM296.1 331.1C285.2 331.1 276.3 339.9 276.3 350.8 276.3 361.7 285.2 370.6 296.1 370.6 307 370.6 315.8 361.7 315.8 350.8 315.8 339.9 307 331.1 296.1 331.1ZM750 567.9C771.8 567.9 789.5 585.6 789.5 607.4V686.3H868.4C890.2 686.3 907.9 704 907.9 725.8 907.9 747.6 890.2 765.3 868.4 765.3H789.5V844.2C789.5 866 771.8 883.7 750 883.7 728.2 883.7 710.5 866 710.5 844.2V765.3H631.6C609.8 765.3 592.1 747.6 592.1 725.8 592.1 704 609.8 686.3 631.6 686.3H710.5V607.4C710.5 585.6 728.2 567.9 750 567.9Z", + "width": 947 + }, + "search": [ + "asc" + ] } ] } \ No newline at end of file diff --git a/packages/ardrive_ui/lib/ardrive_ui.dart b/packages/ardrive_ui/lib/ardrive_ui.dart index 0f0bb7c889..fb8d99ef6b 100644 --- a/packages/ardrive_ui/lib/ardrive_ui.dart +++ b/packages/ardrive_ui/lib/ardrive_ui.dart @@ -1,4 +1,5 @@ library ardrive_ui; export 'src/components.dart'; +export 'src/constants/size_constants.dart'; export 'src/styles.dart'; diff --git a/packages/ardrive_ui/lib/src/components/button.dart b/packages/ardrive_ui/lib/src/components/button.dart index 2b91dc6473..3e46e1e361 100644 --- a/packages/ardrive_ui/lib/src/components/button.dart +++ b/packages/ardrive_ui/lib/src/components/button.dart @@ -1,6 +1,5 @@ import 'package:ardrive_ui/ardrive_ui.dart'; import 'package:ardrive_ui/src/components/breakpoint_layout_builder.dart'; -import 'package:ardrive_ui/src/constants/size_constants.dart'; import 'package:flutter/material.dart'; enum ArDriveButtonStyle { primary, secondary, tertiary } @@ -254,9 +253,6 @@ class ArDriveTextButton extends StatelessWidget { enum ButtonVariant { primary, secondary, outline } -// FIXME: using this from ardrive_ui; move this class to ardrive_ui and remove -const double buttonDefaultHeight = 50; - class ArDriveButtonNew extends StatefulWidget { const ArDriveButtonNew({ super.key, diff --git a/packages/ardrive_ui/lib/src/components/card.dart b/packages/ardrive_ui/lib/src/components/card.dart index 7a57385062..3a7c931fae 100644 --- a/packages/ardrive_ui/lib/src/components/card.dart +++ b/packages/ardrive_ui/lib/src/components/card.dart @@ -16,6 +16,7 @@ class ArDriveCard extends StatelessWidget { this.width, this.boxShadow, this.border, + this.withRedLineOnTop = false, }); final Color? backgroundColor; @@ -27,6 +28,7 @@ class ArDriveCard extends StatelessWidget { final double? width; final BoxShadowCard? boxShadow; final Border? border; + final bool withRedLineOnTop; @override Widget build(BuildContext context) { @@ -50,12 +52,26 @@ class ArDriveCard extends StatelessWidget { ), child: Padding( padding: contentPadding, - child: content, + child: withRedLineOnTop ? _buildRedLineOnTop(context) : content, ), ), ); } + Widget _buildRedLineOnTop(BuildContext context) { + return Column( + children: [ + SizedBox( + height: 6, + child: Container( + color: ArDriveTheme.of(context).themeData.colorTokens.containerRed, + ), + ), + content, + ], + ); + } + BoxShadow _getBoxShadow(BoxShadowCard? boxShadowCard, BuildContext context) { switch (boxShadowCard) { case BoxShadowCard.shadow20: diff --git a/packages/ardrive_ui/lib/src/components/check_box.dart b/packages/ardrive_ui/lib/src/components/check_box.dart index e615c02ee0..d9f87eafc5 100644 --- a/packages/ardrive_ui/lib/src/components/check_box.dart +++ b/packages/ardrive_ui/lib/src/components/check_box.dart @@ -1,5 +1,4 @@ import 'package:ardrive_ui/ardrive_ui.dart'; -import 'package:ardrive_ui/src/constants/size_constants.dart'; import 'package:flutter/material.dart'; // TODO: We only set the checked state on the initial state, to change the state the user must interact with the checkbox. diff --git a/packages/ardrive_ui/lib/src/components/data_table/data_table.dart b/packages/ardrive_ui/lib/src/components/data_table/data_table.dart index fc8632253f..f64e563ea0 100644 --- a/packages/ardrive_ui/lib/src/components/data_table/data_table.dart +++ b/packages/ardrive_ui/lib/src/components/data_table/data_table.dart @@ -1,5 +1,4 @@ import 'package:ardrive_ui/ardrive_ui.dart'; -import 'package:ardrive_ui/src/constants/size_constants.dart'; import 'package:ardrive_ui/src/styles/colors/global_colors.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter/material.dart'; diff --git a/packages/ardrive_ui/lib/src/components/drop_area.dart b/packages/ardrive_ui/lib/src/components/drop_area.dart index 467cec9b67..7ed6c86e1e 100644 --- a/packages/ardrive_ui/lib/src/components/drop_area.dart +++ b/packages/ardrive_ui/lib/src/components/drop_area.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'package:ardrive_io/ardrive_io.dart'; import 'package:ardrive_ui/ardrive_ui.dart'; -import 'package:ardrive_ui/src/constants/size_constants.dart'; import 'package:desktop_drop/desktop_drop.dart'; import 'package:dotted_border/dotted_border.dart'; // ignore: depend_on_referenced_packages diff --git a/packages/ardrive_ui/lib/src/components/modal.dart b/packages/ardrive_ui/lib/src/components/modal.dart index 9fec76a30f..918eef3769 100644 --- a/packages/ardrive_ui/lib/src/components/modal.dart +++ b/packages/ardrive_ui/lib/src/components/modal.dart @@ -1,5 +1,4 @@ import 'package:ardrive_ui/ardrive_ui.dart'; -import 'package:ardrive_ui/src/constants/size_constants.dart'; import 'package:flutter/material.dart'; class ArDriveModal extends StatelessWidget { @@ -93,7 +92,7 @@ class ArDriveModalNew extends StatelessWidget { ), boxShadow: BoxShadowCard.shadow80, backgroundColor: backgroundColor ?? colorTokens.containerL3, - borderRadius: 9, + borderRadius: modalBorderRadius, ), ); } diff --git a/packages/ardrive_ui/lib/src/components/progress_bar.dart b/packages/ardrive_ui/lib/src/components/progress_bar.dart index 3e28a1c680..6458dd661b 100644 --- a/packages/ardrive_ui/lib/src/components/progress_bar.dart +++ b/packages/ardrive_ui/lib/src/components/progress_bar.dart @@ -35,6 +35,7 @@ class _ArDriveProgressBarState extends State { percent: widget.percentage, progressColor: widget.indicatorColor ?? ArDriveTheme.of(context).themeData.colors.themeAccentMuted, + padding: EdgeInsets.zero, ); } } diff --git a/packages/ardrive_ui/lib/src/constants.dart b/packages/ardrive_ui/lib/src/constants.dart deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/packages/ardrive_ui/lib/src/constants/size_constants.dart b/packages/ardrive_ui/lib/src/constants/size_constants.dart index 2d1fcc5e13..53db632238 100644 --- a/packages/ardrive_ui/lib/src/constants/size_constants.dart +++ b/packages/ardrive_ui/lib/src/constants/size_constants.dart @@ -26,3 +26,4 @@ const double checkboxSize = 18; /// Border radius const double checkboxBorderRadius = 3; +const double modalBorderRadius = 9; diff --git a/packages/ardrive_ui/lib/src/styles/icons/ar_drive_icons_icons.dart b/packages/ardrive_ui/lib/src/styles/icons/ar_drive_icons_icons.dart index a741d372c2..4fa4466f86 100644 --- a/packages/ardrive_ui/lib/src/styles/icons/ar_drive_icons_icons.dart +++ b/packages/ardrive_ui/lib/src/styles/icons/ar_drive_icons_icons.dart @@ -14,7 +14,7 @@ /// /// library; -// ignore_for_file: unnecessary_nullable_for_final_variable_declarations, constant_identifier_names +// ignore_for_file: constant_identifier_names import 'package:flutter/widgets.dart'; @@ -22,7 +22,7 @@ class ArDriveIconsData { ArDriveIconsData._(); static const _kFontFam = 'ArDriveIcons'; - static const String? _kFontPkg = 'ardrive_ui'; + static const String _kFontPkg = 'ardrive_ui'; static const IconData icon_add_drive = IconData(0xe800, fontFamily: _kFontFam, fontPackage: _kFontPkg); @@ -148,6 +148,8 @@ class ArDriveIconsData { IconData(0xe83c, fontFamily: _kFontFam, fontPackage: _kFontPkg); static const IconData keypad = IconData(0xe83d, fontFamily: _kFontFam, fontPackage: _kFontPkg); + static const IconData asc = + IconData(0xe83e, fontFamily: _kFontFam, fontPackage: _kFontPkg); static const IconData detach = IconData(0xe83f, fontFamily: _kFontFam, fontPackage: _kFontPkg); static const IconData manifest_icon_flattened = diff --git a/packages/ardrive_ui/lib/src/styles/icons/icons.dart b/packages/ardrive_ui/lib/src/styles/icons/icons.dart index ef86073936..8d280afc60 100644 --- a/packages/ardrive_ui/lib/src/styles/icons/icons.dart +++ b/packages/ardrive_ui/lib/src/styles/icons/icons.dart @@ -481,4 +481,9 @@ class ArDriveIcons { size: size, color: color, ); + static ArDriveIcon asc({double? size, Color? color}) => ArDriveIcon( + icon: ArDriveIconsData.asc, + size: size, + color: color, + ); } diff --git a/packages/ardrive_utils/lib/ardrive_utils.dart b/packages/ardrive_utils/lib/ardrive_utils.dart index 0e7491c367..a56e43bf0c 100644 --- a/packages/ardrive_utils/lib/ardrive_utils.dart +++ b/packages/ardrive_utils/lib/ardrive_utils.dart @@ -18,3 +18,4 @@ export 'src/types/string_types.dart'; export 'src/types/transaction_id.dart'; export 'src/types/winston.dart'; export 'src/validations.dart'; +export 'src/worker.dart'; diff --git a/packages/ardrive_utils/lib/src/worker.dart b/packages/ardrive_utils/lib/src/worker.dart new file mode 100644 index 0000000000..914625b3b6 --- /dev/null +++ b/packages/ardrive_utils/lib/src/worker.dart @@ -0,0 +1,121 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +class Worker { + final Function(T) onTaskCompleted; + final Function(T) execute; + final Function(T, Object e) onError; + final int maxTasks; + final T? task; + + List> taskFutures = []; + + Worker({ + required this.onTaskCompleted, + this.maxTasks = 5, + required this.execute, + required this.onError, + this.task, + }); + + void addTask(T task) { + if (taskFutures.length < maxTasks) { + final future = _execute(task); + taskFutures.add(future); + + future.then((_) { + taskFutures.remove(future); + onTaskCompleted(task); + }); + } + } + + Future _execute(T task) async { + try { + await execute(task); + return; + } catch (e) { + debugPrint('catched error on worker: $e'); + onError(task, e); + } + } +} + +class WorkerPool { + final int numWorkers; + final int maxTasksPerWorker; + final List taskQueue; + late List> workers; + final Function(T) execute; + final Function(T) onWorkerError; + final Completer _completer = Completer(); + int _totalTasks = 0; + int _completedTasks = 0; + + WorkerPool({ + required this.numWorkers, + required this.maxTasksPerWorker, + required this.taskQueue, + required this.execute, + required this.onWorkerError, + }) { + _totalTasks = taskQueue.length; + _setWorkerCallbacks(); + _initializeWorkers(); + } + + void _setWorkerCallbacks() { + workers = List>.generate(numWorkers, (i) { + final worker = Worker( + execute: execute, + onError: (task, exception) => onWorkerError(task), + maxTasks: maxTasksPerWorker, + onTaskCompleted: (task) { + if (_isCanceled) { + return; + } + _completedTasks++; + if (_completedTasks == _totalTasks) { + _completer.complete(); + } + _assignNextTask(i); + }, + ); + return worker; + }); + } + + void _initializeWorkers() { + for (var i = 0; i < numWorkers; i++) { + if (kDebugMode) { + print('Initializing worker with index $i'); + } + + for (var j = 0; j < maxTasksPerWorker; j++) { + if (kDebugMode) { + print('Assigning task $j to worker with index $i'); + } + _assignNextTask(i); + } + } + } + + void _assignNextTask(int workerIndex) { + if (taskQueue.isNotEmpty) { + final nextTask = taskQueue.removeAt(0); + workers[workerIndex].addTask(nextTask); + } + } + + void cancel() { + _isCanceled = true; + _completer.complete(); + } + + bool get isCanceled => _isCanceled; + + bool _isCanceled = false; + + Future get onAllTasksCompleted => _completer.future; +} diff --git a/pubspec.yaml b/pubspec.yaml index 1815f67554..7ef5263b4e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: Secure, permanent storage publish_to: 'none' -version: 2.48.3 +version: 2.49.0 environment: sdk: '>=3.2.0 <4.0.0' diff --git a/test/blocs/data_export_cubit_test.dart b/test/blocs/data_export_cubit_test.dart index 7932880bb1..e67be394ad 100644 --- a/test/blocs/data_export_cubit_test.dart +++ b/test/blocs/data_export_cubit_test.dart @@ -2,6 +2,7 @@ import 'package:ardrive/blocs/data_export/data_export_cubit.dart'; import 'package:ardrive/models/models.dart'; import 'package:ardrive_io/ardrive_io.dart'; import 'package:bloc_test/bloc_test.dart'; +import 'package:mocktail/mocktail.dart'; import 'package:test/test.dart'; import '../snapshots/data_export_snapshot.dart'; @@ -10,6 +11,7 @@ import '../test_utils/utils.dart'; void main() { late Database db; late DriveDao driveDao; + late MockFolderRepository folderRepository; group('DataExport', () { const driveId = 'drive-id'; @@ -22,10 +24,13 @@ void main() { const emptyNestedFolderIdPrefix = 'empty-nested-folder-id'; const emptyNestedFolderCount = 5; + const folderName = 'folder-name'; + const testGatewayURL = 'https://arweave.net'; setUp(() async { db = getTestDb(); driveDao = db.driveDao; + folderRepository = MockFolderRepository(); // Setup mock drive. await addTestFilesToDb( db, @@ -37,6 +42,19 @@ void main() { nestedFolderId: nestedFolderId, nestedFolderFileCount: nestedFolderFileCount, ); + when(() => folderRepository.getLatestFolderRevisionInfo( + any(), + any(), + )).thenAnswer((_) async => FolderRevision( + folderId: '', + name: folderName, + driveId: driveId, + dateCreated: DateTime.now(), + action: 'create', + isHidden: false, + metadataTxId: '', + parentFolderId: '', + )); }); tearDown(() async { await db.close(); @@ -46,6 +64,7 @@ void main() { 'export drive contents as csv file exports the correct number of files', build: () => DataExportCubit( gatewayURL: testGatewayURL, + folderRepository: folderRepository, driveDao: driveDao, driveId: driveId, ), diff --git a/test/core/arfs/drive_repository_test.dart b/test/core/arfs/drive_repository_test.dart new file mode 100644 index 0000000000..73268072ee --- /dev/null +++ b/test/core/arfs/drive_repository_test.dart @@ -0,0 +1,108 @@ +import 'package:ardrive/authentication/ardrive_auth.dart'; +import 'package:ardrive/core/arfs/repository/drive_repository.dart'; +import 'package:ardrive/models/daos/daos.dart'; +import 'package:ardrive/models/database/database.dart'; +import 'package:drift/drift.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../upload/uploader_test.dart'; + +class MockDriveDao extends Mock implements DriveDao {} + +class MockArDriveAuth extends Mock implements ArDriveAuth {} + +class MockDrive extends Mock implements Drive {} + +class MockFileWithLatestRevisionTransactions extends Mock + implements FileWithLatestRevisionTransactions {} + +class MockSelectable extends Mock implements Selectable {} + +void main() { + late DriveRepository driveRepository; + late MockDriveDao mockDriveDao; + late MockArDriveAuth mockArDriveAuth; + late MockSelectable mockSelectableDrive; + late MockSelectable mockSelectableFile; + + setUp(() { + mockDriveDao = MockDriveDao(); + mockArDriveAuth = MockArDriveAuth(); + mockSelectableDrive = MockSelectable(); + mockSelectableFile = MockSelectable(); + driveRepository = + DriveRepository(driveDao: mockDriveDao, auth: mockArDriveAuth); + }); + + group('getAllUserDrives', () { + final mockDrive1 = MockDrive(); + final mockDrive2 = MockDrive(); + const ownerAddress = 'walletAddress'; + + setUp(() { + when(() => mockArDriveAuth.currentUser).thenReturn(getFakeUser()); + }); + + test('returns drives owned by the current user', () async { + when(() => mockDrive1.ownerAddress).thenReturn(ownerAddress); + when(() => mockDrive2.ownerAddress).thenReturn('another_wallet_address'); + when(() => mockSelectableDrive.get()) + .thenAnswer((_) async => [mockDrive1, mockDrive2]); + when(() => mockDriveDao.allDrives()).thenReturn(mockSelectableDrive); + + final result = await driveRepository.getAllUserDrives(); + + expect(result, [mockDrive1]); + }); + + test('returns empty list when there are no drives', () async { + when(() => mockSelectableDrive.get()).thenAnswer((_) async => []); + when(() => mockDriveDao.allDrives()).thenReturn(mockSelectableDrive); + + final result = await driveRepository.getAllUserDrives(); + + expect(result, isEmpty); + }); + + test('returns empty list when user owns no drives', () async { + when(() => mockDrive1.ownerAddress).thenReturn('another_wallet_address'); + when(() => mockSelectableDrive.get()) + .thenAnswer((_) async => [mockDrive1]); + when(() => mockDriveDao.allDrives()).thenReturn(mockSelectableDrive); + + final result = await driveRepository.getAllUserDrives(); + + expect(result, isEmpty); + }); + }); + + group('getAllFileEntriesInDrive', () { + final mockFile1 = MockFileWithLatestRevisionTransactions(); + final mockFile2 = MockFileWithLatestRevisionTransactions(); + const driveId = 'drive_id'; + + test('returns all files in the drive', () async { + when(() => mockSelectableFile.get()) + .thenAnswer((_) async => [mockFile1, mockFile2]); + when(() => mockDriveDao.filesInDriveWithRevisionTransactions( + driveId: driveId)).thenReturn(mockSelectableFile); + + final result = + await driveRepository.getAllFileEntriesInDrive(driveId: driveId); + + expect(result, [mockFile1, mockFile2]); + }); + + test('returns empty list when there are no files in the drive', () async { + when(() => mockSelectableFile.get()).thenAnswer((_) async => []); + when(() => mockDriveDao.filesInDriveWithRevisionTransactions( + driveId: driveId)).thenReturn(mockSelectableFile); + + final result = + await driveRepository.getAllFileEntriesInDrive(driveId: driveId); + + expect(result, isEmpty); + }); + }); +} diff --git a/test/snapshots/data_export_snapshot.dart b/test/snapshots/data_export_snapshot.dart index 9d23e7c6dc..46f5c8369c 100644 --- a/test/snapshots/data_export_snapshot.dart +++ b/test/snapshots/data_export_snapshot.dart @@ -1,12 +1,12 @@ const String dataExportSnapshot = - 'File Id,File Name,Parent Folder ID,Data Transaction ID,Metadata Transaction ID,File Size,Date Created,Last Modified,Direct Download Link\r\n' - 'root-folder-id4,root-folder-id4,root-folder-id,root-folder-id4Data,root-folder-id4Meta,500,2017-09-07 17:30:00.000,2017-09-07 17:30:00.000,https://arweave.net/root-folder-id4Data\r\n' - 'root-folder-id2,root-folder-id2,root-folder-id,root-folder-id2Data,root-folder-id2Meta,500,2017-09-07 17:30:00.000,2017-09-07 17:30:00.000,https://arweave.net/root-folder-id2Data\r\n' - 'root-folder-id3,root-folder-id3,root-folder-id,root-folder-id3Data,root-folder-id3Meta,500,2017-09-07 17:30:00.000,2017-09-07 17:30:00.000,https://arweave.net/root-folder-id3Data\r\n' - 'root-folder-id1,root-folder-id1,root-folder-id,root-folder-id1Data,root-folder-id1Meta,500,2017-09-07 17:30:00.000,2017-09-07 17:30:00.000,https://arweave.net/root-folder-id1Data\r\n' - 'root-folder-id0,root-folder-id0,root-folder-id,root-folder-id0Data,root-folder-id0Meta,500,2017-09-07 17:30:00.000,2017-09-07 17:30:00.000,https://arweave.net/root-folder-id0Data\r\n' - 'nested-folder-id4,nested-folder-id4,nested-folder-id,nested-folder-id4Meta,nested-folder-id4Data,500,2017-09-07 17:30:00.000,2017-09-07 17:30:00.000,https://arweave.net/nested-folder-id4Meta\r\n' - 'nested-folder-id2,nested-folder-id2,nested-folder-id,nested-folder-id2Meta,nested-folder-id2Data,500,2017-09-07 17:30:00.000,2017-09-07 17:30:00.000,https://arweave.net/nested-folder-id2Meta\r\n' - 'nested-folder-id3,nested-folder-id3,nested-folder-id,nested-folder-id3Meta,nested-folder-id3Data,500,2017-09-07 17:30:00.000,2017-09-07 17:30:00.000,https://arweave.net/nested-folder-id3Meta\r\n' - 'nested-folder-id1,nested-folder-id1,nested-folder-id,nested-folder-id1Meta,nested-folder-id1Data,500,2017-09-07 17:30:00.000,2017-09-07 17:30:00.000,https://arweave.net/nested-folder-id1Meta\r\n' - 'nested-folder-id0,nested-folder-id0,nested-folder-id,nested-folder-id0Meta,nested-folder-id0Data,500,2017-09-07 17:30:00.000,2017-09-07 17:30:00.000,https://arweave.net/nested-folder-id0Meta'; + 'File Id,File Name,Parent Folder ID,Parent Folder Name,Data Transaction ID,Metadata Transaction ID,File Size,Date Created,Last Modified,Direct Download Link,Status\r\n' + 'root-folder-id4,root-folder-id4,root-folder-id,folder-name,root-folder-id4Data,root-folder-id4Meta,500,2017-09-07 17:30:00.000,2017-09-07 17:30:00.000,https://arweave.net/root-folder-id4Data,confirmed\r\n' + 'root-folder-id2,root-folder-id2,root-folder-id,folder-name,root-folder-id2Data,root-folder-id2Meta,500,2017-09-07 17:30:00.000,2017-09-07 17:30:00.000,https://arweave.net/root-folder-id2Data,confirmed\r\n' + 'root-folder-id3,root-folder-id3,root-folder-id,folder-name,root-folder-id3Data,root-folder-id3Meta,500,2017-09-07 17:30:00.000,2017-09-07 17:30:00.000,https://arweave.net/root-folder-id3Data,confirmed\r\n' + 'root-folder-id1,root-folder-id1,root-folder-id,folder-name,root-folder-id1Data,root-folder-id1Meta,500,2017-09-07 17:30:00.000,2017-09-07 17:30:00.000,https://arweave.net/root-folder-id1Data,confirmed\r\n' + 'root-folder-id0,root-folder-id0,root-folder-id,folder-name,root-folder-id0Data,root-folder-id0Meta,500,2017-09-07 17:30:00.000,2017-09-07 17:30:00.000,https://arweave.net/root-folder-id0Data,confirmed\r\n' + 'nested-folder-id4,nested-folder-id4,nested-folder-id,folder-name,nested-folder-id4Meta,nested-folder-id4Data,500,2017-09-07 17:30:00.000,2017-09-07 17:30:00.000,https://arweave.net/nested-folder-id4Meta,confirmed\r\n' + 'nested-folder-id2,nested-folder-id2,nested-folder-id,folder-name,nested-folder-id2Meta,nested-folder-id2Data,500,2017-09-07 17:30:00.000,2017-09-07 17:30:00.000,https://arweave.net/nested-folder-id2Meta,confirmed\r\n' + 'nested-folder-id3,nested-folder-id3,nested-folder-id,folder-name,nested-folder-id3Meta,nested-folder-id3Data,500,2017-09-07 17:30:00.000,2017-09-07 17:30:00.000,https://arweave.net/nested-folder-id3Meta,confirmed\r\n' + 'nested-folder-id1,nested-folder-id1,nested-folder-id,folder-name,nested-folder-id1Meta,nested-folder-id1Data,500,2017-09-07 17:30:00.000,2017-09-07 17:30:00.000,https://arweave.net/nested-folder-id1Meta,confirmed\r\n' + 'nested-folder-id0,nested-folder-id0,nested-folder-id,folder-name,nested-folder-id0Meta,nested-folder-id0Data,500,2017-09-07 17:30:00.000,2017-09-07 17:30:00.000,https://arweave.net/nested-folder-id0Meta,confirmed';