diff --git a/lib/blocs/upload/upload_cubit.dart b/lib/blocs/upload/upload_cubit.dart index 0aca4b8acd..751bf01569 100644 --- a/lib/blocs/upload/upload_cubit.dart +++ b/lib/blocs/upload/upload_cubit.dart @@ -54,6 +54,11 @@ class UploadCubit extends Cubit { late Drive _targetDrive; late FolderEntry _targetFolder; UploadMethod? _uploadMethod; + bool _uploadThumbnail = true; + + void changeUploadThumbnailOption(bool uploadThumbnail) { + _uploadThumbnail = uploadThumbnail; + } void setUploadMethod( UploadMethod? method, @@ -601,6 +606,7 @@ class UploadCubit extends Cubit { final uploadController = await ardriveUploader.uploadEntities( entities: entities, wallet: _auth.currentUser.wallet, + uploadThumbnail: _uploadThumbnail, type: _uploadMethod == UploadMethod.ar ? UploadType.d2n : UploadType.turbo, driveKey: driveKey, @@ -722,6 +728,7 @@ class UploadCubit extends Cubit { files: uploadFiles, wallet: _auth.currentUser.wallet, driveKey: driveKey, + uploadThumbnail: _uploadThumbnail, type: _uploadMethod == UploadMethod.ar ? UploadType.d2n : UploadType.turbo, ); @@ -817,6 +824,14 @@ class UploadCubit extends Cubit { ? RevisionAction.uploadNewVersion : RevisionAction.create; + Thumbnail? thumbnail; + + if (fileMetadata.thumbnailInfo != null) { + thumbnail = Thumbnail(variants: [ + Variant.fromJson(fileMetadata.thumbnailInfo!.first.toJson()) + ]); + } + final entity = FileEntity( dataContentType: fileMetadata.dataContentType, dataTxId: fileMetadata.dataTxId, @@ -827,6 +842,7 @@ class UploadCubit extends Cubit { name: fileMetadata.name, parentFolderId: fileMetadata.parentFolderId, size: fileMetadata.size, + thumbnail: thumbnail, // TODO: pinnedDataOwnerAddress ); diff --git a/lib/blocs/upload/upload_state.dart b/lib/blocs/upload/upload_state.dart index c588b84299..b0ec1169bf 100644 --- a/lib/blocs/upload/upload_state.dart +++ b/lib/blocs/upload/upload_state.dart @@ -80,7 +80,6 @@ class UploadReady extends UploadState { final bool isDragNDrop; final bool uploadIsPublic; final int numberOfFiles; - final UploadParams params; final bool isArConnect; diff --git a/lib/components/details_panel.dart b/lib/components/details_panel.dart index 76df3f09ec..4ffbf2bd93 100644 --- a/lib/components/details_panel.dart +++ b/lib/components/details_panel.dart @@ -13,6 +13,7 @@ import 'package:ardrive/components/truncated_address.dart'; import 'package:ardrive/core/arfs/entities/arfs_entities.dart'; import 'package:ardrive/core/crypto/crypto.dart'; import 'package:ardrive/download/multiple_file_download_modal.dart'; +import 'package:ardrive/drive_explorer/thumbnail_creation/page/thumbnail_creation_modal.dart'; import 'package:ardrive/l11n/l11n.dart'; import 'package:ardrive/misc/resources.dart'; import 'package:ardrive/models/models.dart'; @@ -25,6 +26,7 @@ import 'package:ardrive/utils/app_localizations_wrapper.dart'; import 'package:ardrive/utils/filesize.dart'; import 'package:ardrive/utils/num_to_string_parsers.dart'; import 'package:ardrive/utils/open_url.dart'; +import 'package:ardrive/utils/show_general_dialog.dart'; import 'package:ardrive/utils/size_constants.dart'; import 'package:ardrive/utils/user_utils.dart'; import 'package:ardrive_ui/ardrive_ui.dart'; @@ -303,6 +305,27 @@ class _DetailsPanelState extends State { size: 32, ), }, + if (widget.item is FileDataTableItem && + FileTypeHelper.isImage(widget.item.contentType) && + (widget.item as FileDataTableItem).thumbnail == + null) ...{ + ArDriveIconButton( + icon: ArDriveIcons.image(), + tooltip: 'Create Thumbnail', + onPressed: () { + showArDriveDialog( + context, + content: BlocProvider.value( + value: context.read(), + child: ThumbnailCreationModal( + fileDataTableItem: + widget.item as FileDataTableItem, + ), + ), + ); + }, + ) + }, if (widget.currentDrive != null) ScreenTypeLayout.builder( desktop: (context) => const SizedBox.shrink(), @@ -716,15 +739,11 @@ 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}', - ); - }, + Text( + '${state.metadataTxId.substring(0, 4)}...', + style: ArDriveTypography.body + .buttonNormalRegular() + .copyWith(decoration: TextDecoration.underline), ), const SizedBox(width: 12), CopyButton( @@ -739,14 +758,25 @@ 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/${item.dataTxId}', - ); - }, + // 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, + ), + ), + ), + ), ), const SizedBox(width: 12), CopyButton( diff --git a/lib/components/side_bar.dart b/lib/components/side_bar.dart index 083a6446e7..9d49d7fd79 100644 --- a/lib/components/side_bar.dart +++ b/lib/components/side_bar.dart @@ -281,6 +281,8 @@ class _AppSideBarState extends State { } Widget _buildAccordion(DrivesLoadSuccess state, bool isMobile) { + final typography = ArDriveTypographyNew.of(context); + return ArDriveAccordion( contentPadding: isMobile ? const EdgeInsets.all(4) : null, key: ValueKey(state.userDrives.map((e) => e.name)), @@ -291,9 +293,9 @@ class _AppSideBarState extends State { isExpanded: true, Text( appLocalizationsOf(context).publicDrives, - style: ArDriveTypography.body.buttonLargeBold().copyWith( - fontWeight: FontWeight.w700, - ), + style: typography.paragraphNormal( + fontWeight: ArFontWeight.bold, + ), ), state.userDrives .where((element) => element.isPublic) @@ -319,9 +321,9 @@ class _AppSideBarState extends State { isExpanded: true, Text( appLocalizationsOf(context).privateDrives, - style: ArDriveTypography.body - .buttonLargeBold() - .copyWith(fontWeight: FontWeight.w700), + style: typography.paragraphNormal( + fontWeight: ArFontWeight.bold, + ), ), state.userDrives .where((element) => element.isPrivate) @@ -342,9 +344,9 @@ class _AppSideBarState extends State { isExpanded: true, Text( appLocalizationsOf(context).sharedDrives, - style: ArDriveTypography.body - .buttonLargeBold() - .copyWith(fontWeight: FontWeight.w700), + style: typography.paragraphNormal( + fontWeight: ArFontWeight.bold, + ), ), state.sharedDrives .map( @@ -675,6 +677,8 @@ class DriveListTile extends StatelessWidget { @override Widget build(BuildContext context) { + final typography = ArDriveTypographyNew.of(context); + return GestureDetector( key: key, onTap: onTap, @@ -693,19 +697,15 @@ class DriveListTile extends StatelessWidget { child: Text( drive.name, style: isSelected - ? ArDriveTypography.body - .buttonNormalBold( - color: ArDriveTheme.of(context) - .themeData - .colors - .themeFgDefault, - ) - .copyWith(fontWeight: FontWeight.w700) - : ArDriveTypography.body.buttonNormalRegular( + ? typography.paragraphNormal( + fontWeight: ArFontWeight.semiBold, + ) + : typography.paragraphNormal( + fontWeight: ArFontWeight.semiBold, color: ArDriveTheme.of(context) .themeData - .colors - .themeAccentDisabled, + .colorTokens + .textLow, ), ), ), diff --git a/lib/components/upload_form.dart b/lib/components/upload_form.dart index 9fcfc61289..7e19026d14 100644 --- a/lib/components/upload_form.dart +++ b/lib/components/upload_form.dart @@ -419,6 +419,7 @@ class _UploadFormState extends State { ), ); } else if (state is UploadReady) { + final typography = ArDriveTypographyNew.of(context); return ReactiveForm( formGroup: context.watch().licenseCategoryForm, child: ReactiveFormConsumer(builder: (_, form, __) { @@ -478,6 +479,33 @@ class _UploadFormState extends State { }, ), ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Row( + children: [ + ArDriveCheckBox( + title: 'Upload with thumbnails', + checked: true, + titleStyle: typography.paragraphLarge( + fontWeight: ArFontWeight.semiBold, + ), + onChange: (value) { + context + .read() + .changeUploadThumbnailOption(value); + }, + ), + Padding( + padding: const EdgeInsets.only(left: 8.0), + child: ArDriveIconButton( + icon: ArDriveIcons.info(), + tooltip: + 'Uploading with thumbnails is free, but may make your upload take longer.\nYou can always attach a thumbnail later.', + ), + ) + ], + ), + ), SizedBox( child: ReactiveForm( formGroup: @@ -954,6 +982,9 @@ class _UploadFormState extends State { case UploadStatus.creatingBundle: status = 'We are preparing your upload. Preparation step 2/2'; + case UploadStatus.uploadingThumbnail: + status = 'Uploading thumbnail...'; + break; } final statusAvailableForShowingProgress = diff --git a/lib/dev_tools/app_dev_tools.dart b/lib/dev_tools/app_dev_tools.dart index f79540e54c..cee604c94e 100644 --- a/lib/dev_tools/app_dev_tools.dart +++ b/lib/dev_tools/app_dev_tools.dart @@ -1,10 +1,12 @@ import 'dart:convert'; +import 'package:ardrive/dev_tools/drives_health_check.dart'; +import 'package:ardrive/dev_tools/thumbnail_generator_poc.dart'; import 'package:ardrive/main.dart'; -import 'package:ardrive/pages/drive_detail/components/hover_widget.dart'; import 'package:ardrive/services/config/config.dart'; import 'package:ardrive/turbo/topup/blocs/payment_form/payment_form_bloc.dart'; import 'package:ardrive/utils/logger.dart'; +import 'package:ardrive/utils/show_general_dialog.dart'; import 'package:ardrive_ui/ardrive_ui.dart'; import 'package:ardrive_utils/ardrive_utils.dart'; import 'package:flutter/material.dart'; @@ -155,20 +157,6 @@ class AppConfigWindowManagerState extends State { type: ArDriveDevToolOptionType.text, ); - final ArDriveDevToolOption defaultTurboPaymentUrlOption = - ArDriveDevToolOption( - name: 'defaultTurboUrl', - value: config.defaultTurboPaymentUrl, - onChange: (value) { - setState(() { - configService.updateAppConfig( - config.copyWith(defaultTurboPaymentUrl: value), - ); - }); - }, - type: ArDriveDevToolOptionType.text, - ); - final ArDriveDevToolOption stripePublishableKey = ArDriveDevToolOption( name: 'stripePublishableKey', value: config.stripePublishableKey, @@ -274,6 +262,20 @@ class AppConfigWindowManagerState extends State { type: ArDriveDevToolOptionType.button, ); + final ArDriveDevToolOption runHealthCheck = ArDriveDevToolOption( + name: 'Run Health Check', + value: '', + onChange: (value) { + final BuildContext context = ArDriveDevTools().context!; + + showArDriveDialog( + context, + content: const DrivesHealthCheckModal(), + ); + }, + type: ArDriveDevToolOptionType.button, + ); + final ArDriveDevToolOption resetOptions = ArDriveDevToolOption( name: 'Reset options', value: '', @@ -329,6 +331,25 @@ class AppConfigWindowManagerState extends State { type: ArDriveDevToolOptionType.button, ); + final ArDriveDevToolOption pickImageAndGenerateThumbnailItem = + ArDriveDevToolOption( + name: 'Generate Thumbnails', + value: '', + onChange: (value) {}, + onInteraction: () async { + try { + final BuildContext context = ArDriveDevTools().context!; + + showArDriveDialog(context, + content: + const ArDriveStandardModal(content: ThumbnailGeneratorPOC())); + } catch (e) { + logger.e('Error setting default data on payment form', e); + } + }, + type: ArDriveDevToolOptionType.button, + ); + final ArDriveDevToolOption forceNoFreeThanksToTurbo = ArDriveDevToolOption( name: 'forceNoFreeThanksToTurbo', value: config.forceNoFreeThanksToTurbo, @@ -342,23 +363,6 @@ class AppConfigWindowManagerState extends State { type: ArDriveDevToolOptionType.bool, ); - final ArDriveDevToolOption fakeTurboCredits = ArDriveDevToolOption( - name: 'fakeTurboCredits', - value: config.fakeTurboCredits, - onChange: (value) { - late AppConfig newConfig; - if (value == null) { - newConfig = config.copyWith(unsetFakeTurboCredits: true); - } else { - newConfig = config.copyWith(fakeTurboCredits: value); - } - setState(() { - configService.updateAppConfig(newConfig); - }); - }, - type: ArDriveDevToolOptionType.turboCredits, - ); - final ArDriveDevToolOption topUpDryRun = ArDriveDevToolOption( name: 'topUpDryRun', value: config.topUpDryRun, @@ -373,10 +377,11 @@ class AppConfigWindowManagerState extends State { ); final List options = [ + runHealthCheck, useTurboOption, useTurboPaymentOption, - defaultTurboPaymentUrlOption, enableSyncFromSnapshotOption, + pickImageAndGenerateThumbnailItem, stripePublishableKey, enableQuickSyncAuthoringOption, enableMultipleFileDownloadOption, @@ -389,82 +394,99 @@ class AppConfigWindowManagerState extends State { autoSyncIntervalInSecondsOption, turboSetDefaultData, forceNoFreeThanksToTurbo, - fakeTurboCredits, topUpDryRun, reloadOption, resetOptions, ]; + final typography = ArDriveTypographyNew.of(context); + return DraggableWindow( windowTitle: _windowTitle, child: SingleChildScrollView( primary: true, child: Column( children: [ - const SizedBox(height: 16), + const SizedBox(height: 48), FutureBuilder( future: _readConfigsFromEnv(), builder: (context, snapshot) { - return Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - ArDriveButton( - text: 'dev env', - onPressed: () { - setState(() { - _windowTitle.value = 'Reloading...'; - - configService.updateAppConfig( - AppConfig.fromJson(snapshot.data![0]), - ); - }); - - Future.delayed(const Duration(seconds: 1), () { - setState(() { - _windowTitle.value = 'Dev config'; - reloadPage(); - }); - }); - }, - ), - ArDriveButton( - text: 'staging env', - onPressed: () { - setState(() { - _windowTitle.value = 'Reloading...'; - - configService.updateAppConfig( - AppConfig.fromJson(snapshot.data![2]), - ); - }); - - Future.delayed(const Duration(seconds: 1), () { - setState(() { - _windowTitle.value = 'Staging config'; - reloadPage(); - }); - }); - }, - ), - ArDriveButton( - text: 'prod env', - onPressed: () { - setState(() { - _windowTitle.value = 'Reloading...'; - - configService.updateAppConfig( - AppConfig.fromJson(snapshot.data![1]), - ); - }); - - Future.delayed(const Duration(seconds: 1), () { - setState(() { - _windowTitle.value = 'Prod config'; - }); - }); - }, - ) - ], + return Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Flexible( + child: ArDriveButtonNew( + text: 'dev env', + typography: typography, + variant: ButtonVariant.primary, + onPressed: () { + setState(() { + _windowTitle.value = 'Reloading...'; + + configService.updateAppConfig( + AppConfig.fromJson(snapshot.data![0]), + ); + }); + + Future.delayed(const Duration(seconds: 1), () { + setState(() { + _windowTitle.value = 'Dev config'; + reloadPage(); + }); + }); + }, + ), + ), + const SizedBox(width: 16), + Flexible( + child: ArDriveButtonNew( + text: 'staging env', + variant: ButtonVariant.primary, + typography: typography, + onPressed: () { + setState(() { + _windowTitle.value = 'Reloading...'; + + configService.updateAppConfig( + AppConfig.fromJson(snapshot.data![2]), + ); + }); + + Future.delayed(const Duration(seconds: 1), () { + setState(() { + _windowTitle.value = 'Staging config'; + reloadPage(); + }); + }); + }, + ), + ), + const SizedBox(width: 16), + Flexible( + child: ArDriveButtonNew( + text: 'prod env', + variant: ButtonVariant.primary, + typography: typography, + onPressed: () { + setState(() { + _windowTitle.value = 'Reloading...'; + + configService.updateAppConfig( + AppConfig.fromJson(snapshot.data![1]), + ); + }); + + Future.delayed(const Duration(seconds: 1), () { + setState(() { + _windowTitle.value = 'Prod config'; + }); + }); + }, + ), + ) + ], + ), ); }), ListView.separated( @@ -501,7 +523,7 @@ class AppConfigWindowManagerState extends State { Widget buildOption(ArDriveDevToolOption option) { switch (option.type) { case ArDriveDevToolOptionType.text: - return ArDriveTextField( + return ArDriveTextFieldNew( label: option.name, initialValue: option.value, onFieldSubmitted: (value) { @@ -519,7 +541,7 @@ class AppConfigWindowManagerState extends State { }, ); case ArDriveDevToolOptionType.number: - return ArDriveTextField( + return ArDriveTextFieldNew( label: option.name, initialValue: option.value.toString(), onFieldSubmitted: (value) { @@ -530,7 +552,9 @@ class AppConfigWindowManagerState extends State { ); case ArDriveDevToolOptionType.button: - return ArDriveButton( + return ArDriveButtonNew( + variant: ButtonVariant.primary, + typography: ArDriveTypographyNew.of(context), text: option.name, onPressed: () { option.onChange(option.value); @@ -539,8 +563,9 @@ class AppConfigWindowManagerState extends State { ); case ArDriveDevToolOptionType.buttonTertiary: - return ArDriveButton( - style: ArDriveButtonStyle.tertiary, + return ArDriveButtonNew( + variant: ButtonVariant.outline, + typography: ArDriveTypographyNew.of(context), text: option.name, onPressed: () => option.onChange(option.value), ); @@ -672,16 +697,21 @@ class DraggableWindow extends HookWidget { ), Align( alignment: Alignment.topRight, - child: ArDriveIconButton( - icon: ArDriveIcons.closeCircle( - color: ArDriveTheme.of(context) - .themeData - .colors - .themeBgCanvas, + child: Padding( + padding: const EdgeInsets.only(top: 20, right: 8), + child: ArDriveClickArea( + child: GestureDetector( + onTap: () { + ArDriveDevTools().closeDevTools(); + }, + child: ArDriveIcons.x( + color: ArDriveTheme.of(context) + .themeData + .colorTokens + .iconLow, + ), + ), ), - onPressed: () { - ArDriveDevTools().closeDevTools(); - }, ), ), ], diff --git a/lib/dev_tools/drives_health_check.dart b/lib/dev_tools/drives_health_check.dart new file mode 100644 index 0000000000..00b0eba046 --- /dev/null +++ b/lib/dev_tools/drives_health_check.dart @@ -0,0 +1,536 @@ +import 'dart:async'; + +import 'package:ardrive/models/daos/drive_dao/drive_dao.dart'; +import 'package:ardrive/models/database/database.dart'; +import 'package:ardrive/services/arweave/arweave.dart'; +import 'package:ardrive/utils/logger.dart'; +import 'package:ardrive_ui/ardrive_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:http/http.dart' as http; +import 'package:retry/retry.dart'; + +class DrivesHealthCheckModal extends StatefulWidget { + const DrivesHealthCheckModal({super.key}); + + @override + State createState() => _DrivesHealthCheckModalState(); +} + +class _DrivesHealthCheckModalState extends State { + List statuses = []; + List driveStatuses = []; + List drives = []; + int numberOfFiles = 0; + + @override + initState() { + super.initState(); + + final driveDao = context.read(); + + driveDao.select(driveDao.drives).get().then((drives) { + setState(() { + this.drives = drives; + }); + + processDrivesSequentially(); + }); + } + + Future processDrivesSequentially() async { + final driveDao = context.read(); + + for (final drive in drives) { + final status = DriveHealthCheckStatus( + drive: drive, + files: [], + totalFiles: 0, + ); + + driveStatuses.add(status); + } + + setState(() {}); + + for (final currentStatus in driveStatuses) { + final files = await (driveDao.select(driveDao.fileEntries) + ..where((tbl) => tbl.driveId.equals(currentStatus.drive.id))) + .get(); + if (files.isEmpty) { + currentStatus.isLoading = false; + + setState(() {}); + + continue; + } + + currentStatus.totalFiles = files.length; + + selectedDriveStatus = currentStatus; + setState(() {}); + + await processFiles(files, currentStatus); + + currentStatus.isLoading = false; + + setState(() {}); + } + } + + late DriveHealthCheckStatus selectedDriveStatus; + + @override + Widget build(BuildContext context) { + final typography = ArDriveTypographyNew.of(context); + + if (driveStatuses.isEmpty) { + return const Center( + child: CircularProgressIndicator(), + ); + } + + return SizedBox( + child: ArDriveModalNew( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.9, + maxWidth: MediaQuery.of(context).size.width * 0.8, + minHeight: MediaQuery.of(context).size.height * 0.9, + ), + content: Column( + children: [ + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Flexible( + flex: 2, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text('Drives', + style: typography.heading4( + fontWeight: ArFontWeight.bold, + )), + Text('Click on a drive to view details', + style: typography.paragraphNormal( + fontWeight: ArFontWeight.semiBold, + )), + const SizedBox( + height: 8, + ), + SizedBox( + height: MediaQuery.of(context).size.height * 0.72, + child: ListView.separated( + itemCount: driveStatuses.length, + addAutomaticKeepAlives: true, + separatorBuilder: (context, index) => + const Divider(), + shrinkWrap: true, + itemBuilder: (context, index) { + final driveStatus = driveStatuses[index]; + + return ArDriveClickArea( + child: GestureDetector( + onTap: () { + setState(() { + selectedDriveStatus = driveStatus; + }); + }, + child: DriveHealthCheckTile( + status: driveStatus, + key: Key(driveStatus.drive.id), + isSelected: + selectedDriveStatus.drive.id == + driveStatus.drive.id, + ), + ), + ); + }), + ), + ], + ), + ), + ), + const SizedBox(width: 20), + Flexible( + flex: 1, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox( + height: 8, + ), + Text( + 'Selected Drive: ${selectedDriveStatus.drive.name}', + style: typography.paragraphLarge( + fontWeight: ArFontWeight.bold, + ), + ), + Text('Success Files - ${selectedDriveStatus.drive.name}', + style: typography.paragraphLarge()), + const SizedBox( + height: 8, + ), + Flexible( + flex: 1, + child: ArDriveCard( + content: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: + MediaQuery.of(context).size.height * 0.32, + ), + child: Builder(builder: (context) { + final successFiles = selectedDriveStatus.files + .where((element) => element.isSuccess) + .toList(); + if (successFiles.isEmpty) { + return const Center( + child: Text('No files found'), + ); + } + + return ListView.builder( + itemCount: successFiles.length, + addAutomaticKeepAlives: true, + shrinkWrap: true, + itemBuilder: (context, index) { + final status = successFiles[index]; + + return FileHealthCheckTile( + status: status, + onFinish: () async {}, + ); + }); + }), + ), + ), + ), + const SizedBox( + height: 20, + ), + Text('Failed Files - ${selectedDriveStatus.drive.name}', + style: typography.paragraphLarge()), + const SizedBox( + height: 8, + ), + Flexible( + flex: 1, + child: ArDriveCard( + content: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: + MediaQuery.of(context).size.height * 0.32, + ), + child: Builder(builder: (context) { + final failedFiles = selectedDriveStatus.files + .where((element) => element.isFailed) + .toList(); + if (failedFiles.isEmpty) { + return const Center( + child: Text('No files found'), + ); + } + return ListView.builder( + itemCount: failedFiles.length, + addAutomaticKeepAlives: true, + shrinkWrap: true, + itemBuilder: (context, index) { + final status = failedFiles[index]; + + return FileHealthCheckTile( + status: status, + onFinish: () async {}, + ); + }); + }), + ), + ), + ), + ], + ), + ), + ], + ), + const SizedBox( + height: 8, + ), + Text( + 'Drives Loaded: ${driveStatuses.where((element) => !element.isLoading).length} of ${driveStatuses.length}', + style: typography.paragraphLarge( + fontWeight: ArFontWeight.bold, + ), + ), + ], + ), + ), + ); + } + + Future processFiles( + List files, DriveHealthCheckStatus driveStatus) async { + const int maxConcurrentTasks = 20; + final StreamController controller = StreamController(); + + // Function to process files + void processNext() { + if (files.isNotEmpty) { + final file = files.removeAt(0); + checkHealth(file, driveStatus).then((_) { + if (files.isEmpty) { + controller.close(); + return; + } + + controller.add(null); + setState(() {}); + }); + } + } + + // Start initial batch of downloads + for (int i = 0; i < maxConcurrentTasks && files.isNotEmpty; i++) { + processNext(); + setState(() {}); + } + + // Listen for completion events and process next file + await for (final _ in controller.stream) { + processNext(); + setState(() {}); + } + } + + /// checks the health of the file + Future checkHealth( + FileEntry file, DriveHealthCheckStatus driveStatus) async { + try { + final arweave = context.read(); + + final url = + '${arweave.client.api.gatewayUrl.origin}/raw/${file.dataTxId}'; + + final response = await retry(() async => await http.head(Uri.parse(url)), + maxDelay: const Duration(seconds: 300)); + + logger.d( + 'Checking health of ${file.name}. Response: ${response.statusCode}'); + + if (response.statusCode > 400) { + driveStatus.files.add(FileHealthCheckStatus( + file: file, + isSuccess: false, + isFailed: true, + )); + setState(() {}); + return; + } + + driveStatus.files.add(FileHealthCheckStatus( + file: file, + isSuccess: true, + isFailed: false, + )); + + setState(() {}); + } catch (e) { + logger.d('Error checking health of ${file.name}. Error: $e'); + driveStatus.files.add(FileHealthCheckStatus( + file: file, + isSuccess: false, + isFailed: true, + )); + } + } +} + +class DriveHealthCheckTile extends StatefulWidget { + const DriveHealthCheckTile({ + super.key, + required this.status, + this.isSelected = false, + }); + + final DriveHealthCheckStatus status; + final bool isSelected; + + @override + State createState() => _DriveHealthCheckTielState(); +} + +class _DriveHealthCheckTielState extends State { + @override + Widget build(BuildContext context) { + final colorTokens = ArDriveTheme.of(context).themeData.colorTokens; + final failedFiles = + widget.status.files.where((element) => element.isFailed).toList(); + final progress = widget.status.totalFiles == 0 && widget.status.isLoading + ? 0.0 + : widget.status.totalFiles == 0 && !widget.status.isLoading + ? 1.0 + : widget.status.files.length / widget.status.totalFiles; + return ArDriveCard( + content: Column( + children: [ + Row( + children: [ + Text( + widget.status.drive.name, + style: ArDriveTypographyNew.of(context).paragraphLarge( + fontWeight: ArFontWeight.bold, + color: failedFiles.isNotEmpty ? colorTokens.strokeRed : null, + ), + ), + const Spacer(), + if (failedFiles.isNotEmpty) ...[ + ArDriveIcons.triangle(size: 20, color: colorTokens.strokeRed), + const SizedBox(width: 2), + Text(failedFiles.length.toString(), + style: ArDriveTypographyNew.of(context).paragraphLarge( + fontWeight: ArFontWeight.bold, + color: colorTokens.strokeRed, + )), + const SizedBox(width: 8), + ], + Text( + '${widget.status.files.length}/${widget.status.totalFiles}', + style: ArDriveTypographyNew.of(context).paragraphLarge( + fontWeight: ArFontWeight.bold, + ), + ), + ], + ), + const SizedBox( + height: 8, + ), + ArDriveProgressBar( + percentage: progress, + indicatorColor: progress == 1 + ? failedFiles.isNotEmpty + ? colorTokens.strokeRed + : ArDriveTheme.of(context) + .themeData + .colors + .themeSuccessDefault + : colorTokens.textHigh, + backgroundColor: colorTokens.textLow, + ), + ], + ), + ); + } +} + +class DriveHealthCheckStatus { + final Drive drive; + final List files; + int totalFiles; + bool isLoading; + + DriveHealthCheckStatus({ + required this.drive, + required this.files, + required this.totalFiles, + this.isLoading = true, + }); +} + +class FileHealthCheckStatus { + final FileEntry file; + final bool isSuccess; + final bool isFailed; + + FileHealthCheckStatus({ + required this.file, + required this.isSuccess, + required this.isFailed, + }); +} + +class FileHealthCheckTile extends StatefulWidget { + const FileHealthCheckTile( + {super.key, required this.status, required this.onFinish}); + + final Function onFinish; + final FileHealthCheckStatus status; + + @override + State createState() => _FileHealthCheckTileState(); +} + +class _FileHealthCheckTileState extends State { + @override + void initState() { + super.initState(); + } + + @override + Widget build(BuildContext context) { + final typography = ArDriveTypographyNew.of(context); + final colorTokens = ArDriveTheme.of(context).themeData.colorTokens; + if (widget.status.isSuccess) { + return ListTile( + title: Text(widget.status.file.name, + style: typography.paragraphNormal( + fontWeight: ArFontWeight.bold, + color: colorTokens.textHigh, + )), + subtitle: Text( + 'Health check completed', + style: typography.paragraphNormal( + fontWeight: ArFontWeight.semiBold, + color: colorTokens.textMid, + ), + ), + trailing: IconButton( + color: colorTokens.textHigh, + icon: const Icon( + Icons.check, + ), + onPressed: () {}, + ), + ); + } + + if (widget.status.isFailed) { + return ListTile( + title: Text( + widget.status.file.name, + style: typography.paragraphNormal( + fontWeight: ArFontWeight.bold, + color: colorTokens.textHigh, + ), + ), + subtitle: Text( + 'Health check failed', + style: typography.paragraphNormal( + fontWeight: ArFontWeight.semiBold, + color: colorTokens.textLow, + ), + ), + trailing: IconButton( + icon: Icon( + Icons.close, + color: colorTokens.textHigh, + ), + onPressed: () { + widget.onFinish(); + }, + ), + ); + } + + return ListTile( + title: Text(widget.status.file.name, style: typography.paragraphNormal()), + subtitle: Text( + 'Checking health of ${widget.status.file.name}', + style: typography.paragraphNormal( + fontWeight: ArFontWeight.bold, + color: colorTokens.buttonDisabled, + ), + ), + ); + } +} diff --git a/lib/dev_tools/thumbnail_generator_poc.dart b/lib/dev_tools/thumbnail_generator_poc.dart new file mode 100644 index 0000000000..71f63addd7 --- /dev/null +++ b/lib/dev_tools/thumbnail_generator_poc.dart @@ -0,0 +1,194 @@ +// ignore_for_file: use_build_context_synchronously + +import 'package:ardrive/authentication/ardrive_auth.dart'; +import 'package:ardrive/core/crypto/crypto.dart'; +import 'package:ardrive/download/ardrive_downloader.dart'; +import 'package:ardrive/models/models.dart'; +import 'package:ardrive/services/arweave/arweave_service.dart'; +import 'package:ardrive/services/config/config_service.dart'; +import 'package:ardrive/turbo/services/upload_service.dart'; +import 'package:ardrive/utils/logger.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:cryptography/cryptography.dart'; +import 'package:drift/drift.dart' as drift; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class ThumbnailGeneratorPOC extends StatefulWidget { + const ThumbnailGeneratorPOC({super.key}); + + @override + State createState() => _ThumbnailGeneratorPOCState(); +} + +class _ThumbnailGeneratorPOCState extends State { + List? _files; + final Map _thumbnailsGenerated = {}; + + // load files + Future _generateThumbnailsForFiles({required String driveId}) async { + final driveDao = context.read(); + + final files = await (driveDao.select(driveDao.fileEntries) + ..where((tbl) => tbl.driveId.equals(driveId))) + .get(); + + setState(() { + _files = files; + }); + final arweaveService = context.read(); + final turboUploadService = context.read(); + final wallet = context.read().currentUser.wallet; + final downloader = ArDriveDownloader( + ardriveIo: ArDriveIO(), + arweave: arweaveService, + ioFileAdapter: IOFileAdapter(), + ); + + for (var f in files) { + if (FileTypeHelper.isImage(f.dataContentType ?? '') == false) { + logger.i('Skipping file'); + continue; + } + + final dataTx = await arweaveService.getTransactionDetails(f.dataTxId); + + SecretKey? fileKey; + + final drive = await driveDao.driveById(driveId: driveId).getSingle(); + + if (drive.isPrivate) { + final driveKey = await driveDao.getDriveKey( + drive.id, + context.read().currentUser.cipherKey, + ); + + fileKey = await ArDriveCrypto().deriveFileKey( + driveKey!, + f.id, + ); + } + + final bytes = await downloader.downloadToMemory( + dataTx: dataTx!, + fileSize: f.size, + fileName: f.name, + lastModifiedDate: f.lastModifiedDate, + contentType: f.dataContentType!, + isManifest: false, + fileKey: fileKey, + ); + + final uploader = ArDriveUploader( + turboUploadUri: Uri.parse( + context.read().config.defaultTurboUploadUrl!)); + + final data = await generateThumbnail(bytes, ThumbnailSize.small); + + final file = await IOFileAdapter().fromData( + data.thumbnail, + name: 'thumbnail', + lastModifiedDate: DateTime.now(), + ); + + final thumbnailArgs = ThumbnailUploadMetadata( + contentType: 'image/png', + height: data.height, + width: data.width, + size: data.thumbnail.length, + relatesTo: f.dataTxId, + name: data.name, + originalFileId: f.id, + ); + + final controller = await uploader.uploadThumbnail( + thumbnailMetadata: thumbnailArgs, + file: file, + type: UploadType.turbo, + wallet: context.read().currentUser.wallet, + fileKey: fileKey, + ); + + controller.onDone((tasks) async { + logger.i('Thumbnail uploaded'); + + setState(() { + _thumbnailsGenerated[f.dataTxId] = true; + }); + + await driveDao.transaction(() async { + f = f.copyWith( + lastUpdated: DateTime.now(), + thumbnail: drift.Value( + (tasks.first as ThumbnailUploadTask).metadata.toJson().toString(), + ), + ); + + final fileEntity = f.asEntity(); + + if (turboUploadService.useTurboUpload) { + final fileDataItem = await arweaveService.prepareEntityDataItem( + fileEntity, + wallet, + // key: fileKey, + ); + + await turboUploadService.postDataItem( + dataItem: fileDataItem, + wallet: wallet, + ); + fileEntity.txId = fileDataItem.id; + } else {} + + logger.i( + 'Updating file ${f.id} with txId ${fileEntity.txId}. Data content type: ${fileEntity.dataContentType}'); + + await driveDao.writeToFile(f); + + await driveDao.insertFileRevision(fileEntity.toRevisionCompanion( + performedAction: RevisionAction.rename)); + }); + }); + } + } + + final textController = TextEditingController(); + + @override + Widget build(BuildContext context) { + if (_files == null) { + return Center( + child: Column( + children: [ + ArDriveTextFieldNew( + controller: textController, + ), + ElevatedButton( + onPressed: () => + _generateThumbnailsForFiles(driveId: textController.text), + child: const Text('Generate Thumbnails'), + ), + ], + ), + ); + } else { + return ListView.builder( + itemCount: _files!.length, + shrinkWrap: true, + itemBuilder: (context, index) { + final file = _files![index]; + return ListTile( + title: Text(file.name), + subtitle: Text(file.dataTxId), + trailing: _thumbnailsGenerated[file.dataTxId] == true + ? const Icon(Icons.check) + : const Icon(Icons.close), + ); + }, + ); + } + } +} diff --git a/lib/download/ardrive_downloader.dart b/lib/download/ardrive_downloader.dart index f6b99f6018..80bd9375e7 100644 --- a/lib/download/ardrive_downloader.dart +++ b/lib/download/ardrive_downloader.dart @@ -25,7 +25,18 @@ abstract class ArDriveDownloader { String? cipher, String? cipherIvString, }); - + Future downloadToMemory({ + required TransactionCommonMixin dataTx, + required int fileSize, + required String fileName, + required DateTime lastModifiedDate, + required String contentType, + required bool isManifest, + Completer? cancelWithReason, + SecretKey? fileKey, + String? cipher, + String? cipherIvString, + }); Future abortDownload(); factory ArDriveDownloader({ @@ -272,4 +283,33 @@ class _ArDriveDownloader implements ArDriveDownloader { return; } + + @override + Future downloadToMemory({ + required TransactionCommonMixin dataTx, + required int fileSize, + required String fileName, + required DateTime lastModifiedDate, + required String contentType, + required bool isManifest, + Completer? cancelWithReason, + SecretKey? fileKey, + String? cipher, + String? cipherIvString, + }) async { + final stream = await _getFileStream( + dataTx: dataTx, + fileSize: fileSize, + fileName: fileName, + lastModifiedDate: lastModifiedDate, + contentType: contentType, + fileKey: fileKey, + cipher: cipher, + cipherIvString: cipherIvString, + ); + + final data = await stream.toList(); + + return Uint8List.fromList(data.expand((element) => element).toList()); + } } diff --git a/lib/drive_explorer/thumbnail/repository/thumbnail_repository.dart b/lib/drive_explorer/thumbnail/repository/thumbnail_repository.dart new file mode 100644 index 0000000000..9ca66542d0 --- /dev/null +++ b/lib/drive_explorer/thumbnail/repository/thumbnail_repository.dart @@ -0,0 +1,196 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:ardrive/authentication/ardrive_auth.dart'; +import 'package:ardrive/core/crypto/crypto.dart'; +import 'package:ardrive/download/ardrive_downloader.dart'; +import 'package:ardrive/drive_explorer/thumbnail/thumbnail.dart'; +import 'package:ardrive/models/models.dart'; +import 'package:ardrive/pages/drive_detail/drive_detail_page.dart'; +import 'package:ardrive/services/arweave/arweave.dart'; +import 'package:ardrive/turbo/services/upload_service.dart'; +import 'package:ardrive/utils/logger.dart'; +import 'package:ardrive_io/ardrive_io.dart'; +import 'package:ardrive_uploader/ardrive_uploader.dart'; +import 'package:ardrive_utils/ardrive_utils.dart'; +import 'package:cryptography/cryptography.dart'; +import 'package:drift/drift.dart' as drift; + +class ThumbnailRepository { + final ArweaveService _arweaveService; + final ArDriveDownloader _arDriveDownloader; + final DriveDao _driveDao; + final ArDriveAuth _arDriveAuth; + final ArDriveUploader _arDriveUploader; + final TurboUploadService _turboUploadService; + + ThumbnailRepository({ + required ArweaveService arweaveService, + required ArDriveDownloader arDriveDownloader, + required DriveDao driveDao, + required ArDriveAuth arDriveAuth, + required ArDriveUploader arDriveUploader, + required TurboUploadService turboUploadService, + }) : _arDriveUploader = arDriveUploader, + _driveDao = driveDao, + _arDriveDownloader = arDriveDownloader, + _arweaveService = arweaveService, + _turboUploadService = turboUploadService, + _arDriveAuth = arDriveAuth; + + Future getThumbnail({ + FileDataTableItem? fileDataTableItem, + }) async { + final drive = await _driveDao + .driveById(driveId: fileDataTableItem!.driveId) + .getSingle(); + + if (drive.isPrivate) { + return ThumbnailData( + data: await _getThumbnailData(fileDataTableItem: fileDataTableItem), + url: null); + } + + final urlString = + '${_arweaveService.client.api.gatewayUrl.origin}/raw/${fileDataTableItem.thumbnail?.variants.first.txId}'; + + return ThumbnailData(data: null, url: urlString); + } + + Future _getThumbnailData({ + required FileDataTableItem fileDataTableItem, + }) async { + final dataTx = await _arweaveService.getTransactionDetails( + fileDataTableItem.thumbnail!.variants.first.txId, + ); + + if (dataTx == null) { + throw Exception('Data transaction not found'); + } + + final driveKey = await _driveDao.getDriveKey( + fileDataTableItem.driveId, _arDriveAuth.currentUser.cipherKey); + + return await _arDriveDownloader.downloadToMemory( + dataTx: dataTx, + fileSize: fileDataTableItem.thumbnail!.variants.first.size, + fileName: fileDataTableItem.name, + lastModifiedDate: fileDataTableItem.lastModifiedDate, + contentType: 'image/jpeg', + isManifest: false, + cipher: dataTx.getTag(EntityTag.cipher), + cipherIvString: dataTx.getTag(EntityTag.cipherIv), + fileKey: await _driveDao.getFileKey(fileDataTableItem.id, driveKey!), + ); + } + + Future uploadThumbnail({ + required String fileId, + }) async { + var fileEntry = await (_driveDao.select(_driveDao.fileEntries) + ..where((tbl) => tbl.id.equals(fileId))) + .getSingle(); + // get image + final dataTx = + await _arweaveService.getTransactionDetails(fileEntry.dataTxId); + + SecretKey? fileKey; + + final drive = + await _driveDao.driveById(driveId: fileEntry.driveId).getSingle(); + + if (drive.isPrivate) { + final driveKey = await _driveDao.getDriveKey( + drive.id, + _arDriveAuth.currentUser.cipherKey, + ); + + fileKey = await ArDriveCrypto().deriveFileKey( + driveKey!, + fileEntry.id, + ); + } + + final bytes = await _arDriveDownloader.downloadToMemory( + dataTx: dataTx!, + fileSize: fileEntry.size, + fileName: fileEntry.name, + lastModifiedDate: fileEntry.lastModifiedDate, + contentType: fileEntry.dataContentType!, + isManifest: false, + fileKey: fileKey, + cipher: dataTx.getTag(EntityTag.cipher), + cipherIvString: dataTx.getTag(EntityTag.cipherIv), + ); + + final data = await generateThumbnail(bytes, ThumbnailSize.small); + + final thumbnailFile = await IOFileAdapter().fromData( + data.thumbnail, + name: 'thumbnail', + lastModifiedDate: DateTime.now(), + ); + + final thumbnailMetadata = ThumbnailUploadMetadata( + contentType: 'image/jpeg', + height: data.height, + width: data.width, + size: data.thumbnail.length, + relatesTo: fileEntry.dataTxId, + name: data.name, + originalFileId: fileEntry.id, + ); + + final controller = await _arDriveUploader.uploadThumbnail( + thumbnailMetadata: thumbnailMetadata, + file: thumbnailFile, + type: UploadType.turbo, + wallet: _arDriveAuth.currentUser.wallet, + fileKey: fileKey, + ); + + Completer completer = Completer(); + + controller.onDone((tasks) async { + logger.i('Thumbnail uploaded'); + + await _driveDao.transaction(() async { + final thumbnailTask = tasks.first as ThumbnailUploadTask; + + final thumbnailData = { + 'variants': [thumbnailTask.metadata.toJson()] + }; + + fileEntry = fileEntry.copyWith( + lastUpdated: DateTime.now(), + thumbnail: drift.Value(jsonEncode(thumbnailData)), + ); + + final fileEntity = fileEntry.asEntity(); + + final fileDataItem = await _arweaveService.prepareEntityDataItem( + fileEntity, + _arDriveAuth.currentUser.wallet, + key: fileKey, + ); + + await _turboUploadService.postDataItem( + dataItem: fileDataItem, + wallet: _arDriveAuth.currentUser.wallet, + ); + fileEntity.txId = fileDataItem.id; + + await _driveDao.writeToFile(fileEntry); + fileEntity.txId = fileDataItem.id; + + await _driveDao.insertFileRevision(fileEntity.toRevisionCompanion( + performedAction: RevisionAction.createThumbnail)); + + completer.complete(); + }); + }); + + return completer.future; + } +} diff --git a/lib/drive_explorer/thumbnail/thumbnail.dart b/lib/drive_explorer/thumbnail/thumbnail.dart new file mode 100644 index 0000000000..ab6c3821ab --- /dev/null +++ b/lib/drive_explorer/thumbnail/thumbnail.dart @@ -0,0 +1,8 @@ +import 'dart:typed_data'; + +class ThumbnailData { + final Uint8List? data; + final String? url; + + ThumbnailData({this.data, this.url}); +} diff --git a/lib/drive_explorer/thumbnail/thumbnail_bloc.dart b/lib/drive_explorer/thumbnail/thumbnail_bloc.dart new file mode 100644 index 0000000000..952c08f82e --- /dev/null +++ b/lib/drive_explorer/thumbnail/thumbnail_bloc.dart @@ -0,0 +1,31 @@ +import 'package:ardrive/drive_explorer/thumbnail/repository/thumbnail_repository.dart'; +import 'package:ardrive/drive_explorer/thumbnail/thumbnail.dart'; +import 'package:ardrive/pages/drive_detail/drive_detail_page.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +part 'thumbnail_event.dart'; +part 'thumbnail_state.dart'; + +class ThumbnailBloc extends Bloc { + final ThumbnailRepository _thumbnailRepository; + + ThumbnailBloc({ + required ThumbnailRepository thumbnailRepository, + }) : _thumbnailRepository = thumbnailRepository, + super(ThumbnailInitial()) { + on((event, emit) async { + emit(ThumbnailLoading()); + + try { + final thumbnail = await _thumbnailRepository.getThumbnail( + fileDataTableItem: event.fileDataTableItem, + ); + + emit(ThumbnailLoaded(thumbnail: thumbnail)); + } catch (e) { + emit(ThumbnailError()); + } + }); + } +} diff --git a/lib/drive_explorer/thumbnail/thumbnail_event.dart b/lib/drive_explorer/thumbnail/thumbnail_event.dart new file mode 100644 index 0000000000..96633a501a --- /dev/null +++ b/lib/drive_explorer/thumbnail/thumbnail_event.dart @@ -0,0 +1,17 @@ +part of 'thumbnail_bloc.dart'; + +sealed class ThumbnailEvent extends Equatable { + const ThumbnailEvent(); + + @override + List get props => []; +} + +final class GetThumbnail extends ThumbnailEvent { + final FileDataTableItem fileDataTableItem; + + const GetThumbnail({required this.fileDataTableItem}); + + @override + List get props => [fileDataTableItem]; +} diff --git a/lib/drive_explorer/thumbnail/thumbnail_state.dart b/lib/drive_explorer/thumbnail/thumbnail_state.dart new file mode 100644 index 0000000000..b221eb247f --- /dev/null +++ b/lib/drive_explorer/thumbnail/thumbnail_state.dart @@ -0,0 +1,23 @@ +part of 'thumbnail_bloc.dart'; + +sealed class ThumbnailState extends Equatable { + const ThumbnailState(); + + @override + List get props => []; +} + +final class ThumbnailInitial extends ThumbnailState {} + +final class ThumbnailLoading extends ThumbnailState {} + +final class ThumbnailLoaded extends ThumbnailState { + final ThumbnailData thumbnail; + + const ThumbnailLoaded({required this.thumbnail}); + + @override + List get props => [thumbnail]; +} + +final class ThumbnailError extends ThumbnailState {} diff --git a/lib/drive_explorer/thumbnail_creation/bloc/thumbnail_creation_bloc.dart b/lib/drive_explorer/thumbnail_creation/bloc/thumbnail_creation_bloc.dart new file mode 100644 index 0000000000..60ba8f0f35 --- /dev/null +++ b/lib/drive_explorer/thumbnail_creation/bloc/thumbnail_creation_bloc.dart @@ -0,0 +1,36 @@ +import 'package:ardrive/drive_explorer/thumbnail/repository/thumbnail_repository.dart'; +import 'package:ardrive/pages/drive_detail/drive_detail_page.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +part 'thumbnail_creation_event.dart'; +part 'thumbnail_creation_state.dart'; + +class ThumbnailCreationBloc + extends Bloc { + final ThumbnailRepository _thumbnailRepository; + + ThumbnailCreationBloc({ + required ThumbnailRepository thumbnailRepository, + }) : _thumbnailRepository = thumbnailRepository, + super( + ThumbnailCreationInitial(), + ) { + on((event, emit) async { + if (state is ThumbnailCreationLoading) return; + + emit(ThumbnailCreationLoading()); + + try { + (event as CreateThumbnail); + + await _thumbnailRepository.uploadThumbnail( + fileId: event.fileDataTableItem.id); + + emit(ThumbnailCreationSuccess()); + } catch (e) { + emit(ThumbnailCreationError()); + } + }); + } +} diff --git a/lib/drive_explorer/thumbnail_creation/bloc/thumbnail_creation_event.dart b/lib/drive_explorer/thumbnail_creation/bloc/thumbnail_creation_event.dart new file mode 100644 index 0000000000..53cdb607c6 --- /dev/null +++ b/lib/drive_explorer/thumbnail_creation/bloc/thumbnail_creation_event.dart @@ -0,0 +1,17 @@ +part of 'thumbnail_creation_bloc.dart'; + +sealed class ThumbnailCreationEvent extends Equatable { + const ThumbnailCreationEvent(); + + @override + List get props => []; +} + +final class CreateThumbnail extends ThumbnailCreationEvent { + final FileDataTableItem fileDataTableItem; + + const CreateThumbnail({required this.fileDataTableItem}); + + @override + List get props => [fileDataTableItem]; +} diff --git a/lib/drive_explorer/thumbnail_creation/bloc/thumbnail_creation_state.dart b/lib/drive_explorer/thumbnail_creation/bloc/thumbnail_creation_state.dart new file mode 100644 index 0000000000..0d18824209 --- /dev/null +++ b/lib/drive_explorer/thumbnail_creation/bloc/thumbnail_creation_state.dart @@ -0,0 +1,16 @@ +part of 'thumbnail_creation_bloc.dart'; + +sealed class ThumbnailCreationState extends Equatable { + const ThumbnailCreationState(); + + @override + List get props => []; +} + +final class ThumbnailCreationInitial extends ThumbnailCreationState {} + +final class ThumbnailCreationLoading extends ThumbnailCreationState {} + +final class ThumbnailCreationSuccess extends ThumbnailCreationState {} + +final class ThumbnailCreationError extends ThumbnailCreationState {} diff --git a/lib/drive_explorer/thumbnail_creation/page/thumbnail_creation_modal.dart b/lib/drive_explorer/thumbnail_creation/page/thumbnail_creation_modal.dart new file mode 100644 index 0000000000..1dda8c6dd2 --- /dev/null +++ b/lib/drive_explorer/thumbnail_creation/page/thumbnail_creation_modal.dart @@ -0,0 +1,101 @@ +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'; + +class ThumbnailCreationModal extends StatelessWidget { + const ThumbnailCreationModal({super.key, required this.fileDataTableItem}); + + final FileDataTableItem fileDataTableItem; + + @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(), + ), + child: BlocProvider( + create: (context) { + return ThumbnailCreationBloc( + thumbnailRepository: context.read()); + }, + child: _ThumbnailCreationModal( + fileDataTableItem: fileDataTableItem, + ), + ), + ); + } +} + +class _ThumbnailCreationModal extends StatelessWidget { + const _ThumbnailCreationModal({required this.fileDataTableItem}); + + final FileDataTableItem fileDataTableItem; + + @override + Widget build(BuildContext context) { + return ArDriveStandardModalNew( + title: 'Create Thumbnail', + content: BlocConsumer( + listener: (context, state) { + if (state is ThumbnailCreationSuccess) { + context.read().refreshDriveDataTable(); + Navigator.of(context).pop(); + } + }, + builder: (context, state) { + if (state is ThumbnailCreationLoading) { + return const Center(child: CircularProgressIndicator()); + } else if (state is ThumbnailCreationError) { + return const Text( + 'An error occurred while creating the thumbnail.'); + } else if (state is ThumbnailCreationSuccess) { + return const Text('Thumbnail created successfully.'); + } + + return const Column( + children: [ + Text( + 'A thumbnail is a small image that represents your file. ' + 'It will be displayed in the file explorer and in the file details view.', + ), + ], + ); + }, + ), + actions: [ + ModalAction( + action: () { + Navigator.of(context).pop(); + }, + title: 'Cancel'), + ModalAction( + isEnable: context.watch().state + is! ThumbnailCreationLoading, + action: () { + context.read().add( + CreateThumbnail( + fileDataTableItem: fileDataTableItem, + ), + ); + }, + title: 'Confirm', + ), + ], + ); + } +} diff --git a/lib/entities/file_entity.dart b/lib/entities/file_entity.dart index 415b561492..cd1ab4d745 100644 --- a/lib/entities/file_entity.dart +++ b/lib/entities/file_entity.dart @@ -39,6 +39,9 @@ class FileEntity extends EntityWithCustomMetadata { @JsonKey(includeIfNull: false) bool? isHidden; + @JsonKey(includeFromJson: true, includeToJson: true) + Thumbnail? thumbnail; + @override @JsonKey(includeFromJson: false, includeToJson: false) List reservedGqlTags = [ @@ -71,6 +74,7 @@ class FileEntity extends EntityWithCustomMetadata { this.dataContentType, this.pinnedDataOwnerAddress, this.isHidden, + this.thumbnail, }) : super(ArDriveCrypto()); FileEntity.withUserProvidedDetails({ @@ -157,8 +161,68 @@ class FileEntity extends EntityWithCustomMetadata { Map toJson() { final thisJson = _$FileEntityToJson(this); + thisJson['thumbnail'] = thumbnail?.toJson(); + final custom = customJsonMetadata ?? {}; final merged = {...thisJson, ...custom}; return merged; } } + +class Thumbnail { + List variants; + + Thumbnail({required this.variants}); + + factory Thumbnail.fromJson(Map json) { + var variantsJson = json['variants'] as List; + List variantsList = + variantsJson.map((i) => Variant.fromJson(i)).toList(); + + return Thumbnail( + variants: variantsList, + ); + } + + Map toJson() { + return { + 'variants': variants.map((variant) => variant.toJson()).toList(), + }; + } +} + +class Variant { + String name; + String txId; + int size; + int width; + int height; + + Variant({ + required this.name, + required this.txId, + required this.size, + required this.width, + required this.height, + }); + + factory Variant.fromJson(Map json) { + return Variant( + name: json['name'], + txId: json['txId'], + size: json['size'], + width: json['width'], + height: json['height'], + ); + } + + Map toJson() { + return { + 'name': name, + 'txId': txId, + 'size': size, + 'width': width, + 'height': height, + }; + } +} diff --git a/lib/main.dart b/lib/main.dart index 5c1fc50d80..4dde471a3c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -14,6 +14,7 @@ import 'package:ardrive/core/arfs/repository/folder_repository.dart'; 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/models/database/database_helpers.dart'; import 'package:ardrive/services/authentication/biometric_authentication.dart'; import 'package:ardrive/services/config/config_fetcher.dart'; @@ -38,6 +39,7 @@ import 'package:ardrive/utils/upload_plan_utils.dart'; import 'package:ardrive_http/ardrive_http.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:arweave/arweave.dart'; import 'package:flutter/foundation.dart'; @@ -431,5 +433,26 @@ class AppState extends State { _.read(), ), ), + RepositoryProvider( + create: (_) => ArDriveDownloader( + ardriveIo: ArDriveIO(), + arweave: _arweave, + ioFileAdapter: IOFileAdapter(), + ), + ), + // ArDriveUploader + RepositoryProvider( + create: (_) => ArDriveUploader( + arweave: _arweave.client, + turboUploadUri: + Uri.parse(configService.config.defaultTurboUploadUrl!), + metadataGenerator: ARFSUploadMetadataGenerator( + tagsGenerator: ARFSTagsGenetator( + appInfoServices: AppInfoServices(), + ), + ), + pstService: _.read(), + ), + ), ]; } diff --git a/lib/models/daos/drive_dao/drive_dao.dart b/lib/models/daos/drive_dao/drive_dao.dart index e438e4c74c..2945b2c3f5 100644 --- a/lib/models/daos/drive_dao/drive_dao.dart +++ b/lib/models/daos/drive_dao/drive_dao.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'package:ardrive/core/crypto/crypto.dart'; import 'package:ardrive/entities/entities.dart'; @@ -591,6 +592,9 @@ class DriveDao extends DatabaseAccessor with _$DriveDaoMixin { isHidden: Value(entity.isHidden ?? false), // TODO: path is not used in the app, so it's not necessary to set it path: '', + thumbnail: entity.thumbnail != null + ? Value(jsonEncode(entity.thumbnail!.toJson())) + : const Value(null), ); return into(fileEntries).insert( @@ -631,7 +635,7 @@ class DriveDao extends DatabaseAccessor with _$DriveDaoMixin { LicensesCompanion license, ) async { await db.transaction(() async { - await Future.wait( + await Future.wait( license.getTransactionCompanions().map((tx) => writeTransaction(tx))); await into(licenses).insert(license); }); diff --git a/lib/models/database/database.dart b/lib/models/database/database.dart index 051223de36..78691b8a4c 100644 --- a/lib/models/database/database.dart +++ b/lib/models/database/database.dart @@ -28,7 +28,7 @@ class Database extends _$Database { Database([QueryExecutor? e]) : super(e ?? openConnection()); @override - int get schemaVersion => 19; + int get schemaVersion => 20; @override MigrationStrategy get migration => MigrationStrategy( onCreate: (Migrator m) { @@ -108,6 +108,14 @@ class Database extends _$Database { await m.addColumn(fileEntries, fileEntries.licenseTxId); await m.addColumn(fileRevisions, fileRevisions.licenseTxId); } + + if (from < 20) { + // Adding snapshot entries + logger.d('Migrating schema from v19 to v20'); + + await m.addColumn(fileEntries, fileEntries.thumbnail); + await m.addColumn(fileRevisions, fileRevisions.thumbnail); + } } } catch (e, stacktrace) { logger.e( diff --git a/lib/models/enums.dart b/lib/models/enums.dart index a2c8b71a01..8793183bf2 100644 --- a/lib/models/enums.dart +++ b/lib/models/enums.dart @@ -6,6 +6,7 @@ abstract class RevisionAction { static const hide = 'hide'; static const unhide = 'unhide'; static const assertLicense = 'assert-license'; + static const createThumbnail = 'create-thumbnail'; } abstract class TransactionStatus { diff --git a/lib/models/file_entry.dart b/lib/models/file_entry.dart index 0895400e46..55a73f2e91 100644 --- a/lib/models/file_entry.dart +++ b/lib/models/file_entry.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:ardrive/entities/entities.dart'; import 'package:ardrive/utils/custom_metadata.dart'; @@ -17,6 +19,7 @@ extension FileEntryExtensions on FileEntry { dataContentType: dataContentType, pinnedDataOwnerAddress: pinnedDataOwnerAddress, isHidden: isHidden, + thumbnail: Thumbnail.fromJson(jsonDecode(thumbnail!)), ); file.customJsonMetadata = parseCustomJsonMetadata(customJsonMetadata); diff --git a/lib/models/file_revision.dart b/lib/models/file_revision.dart index 0f64df08d5..9e94cbfdcf 100644 --- a/lib/models/file_revision.dart +++ b/lib/models/file_revision.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:ardrive/entities/entities.dart'; import 'package:drift/drift.dart'; @@ -25,6 +27,7 @@ extension FileRevisionsCompanionExtensions on FileRevisionsCompanion { isHidden: isHidden, // TODO: path is not used in the app, so it's not necessary to set it path: '', + thumbnail: Value(thumbnail.value), ); /// Returns a list of [NetworkTransactionsCompanion] representing the metadata and data transactions @@ -43,26 +46,29 @@ extension FileEntityExtensions on FileEntity { /// This requires a `performedAction` to be specified. FileRevisionsCompanion toRevisionCompanion({ required String performedAction, - }) => - FileRevisionsCompanion.insert( - fileId: id!, - driveId: driveId!, - name: name!, - parentFolderId: parentFolderId!, - size: size!, - lastModifiedDate: lastModifiedDate ?? DateTime.now(), - metadataTxId: txId, - dataTxId: dataTxId!, - licenseTxId: Value(licenseTxId), - dateCreated: Value(createdAt), - dataContentType: Value(dataContentType), - action: performedAction, - bundledIn: Value(bundledIn), - customGQLTags: Value(customGqlTagsAsString), - customJsonMetadata: Value(customJsonMetadataAsString), - pinnedDataOwnerAddress: Value(pinnedDataOwnerAddress), - isHidden: Value(isHidden ?? false), - ); + }) { + final thumbnailData = jsonEncode(thumbnail?.toJson()); + return FileRevisionsCompanion.insert( + fileId: id!, + driveId: driveId!, + name: name!, + parentFolderId: parentFolderId!, + size: size!, + lastModifiedDate: lastModifiedDate ?? DateTime.now(), + metadataTxId: txId, + dataTxId: dataTxId!, + licenseTxId: Value(licenseTxId), + dateCreated: Value(createdAt), + dataContentType: Value(dataContentType), + action: performedAction, + bundledIn: Value(bundledIn), + customGQLTags: Value(customGqlTagsAsString), + customJsonMetadata: Value(customJsonMetadataAsString), + pinnedDataOwnerAddress: Value(pinnedDataOwnerAddress), + isHidden: Value(isHidden ?? false), + thumbnail: Value(thumbnailData), + ); + } FileRevision toRevision({ required String performedAction, @@ -85,6 +91,7 @@ extension FileEntityExtensions on FileEntity { customJsonMetadata: customJsonMetadataAsString, pinnedDataOwnerAddress: pinnedDataOwnerAddress, isHidden: isHidden ?? false, + thumbnail: jsonEncode(thumbnail?.toJson()), ); /// Returns the action performed on the file that lead to the new revision. @@ -104,6 +111,9 @@ extension FileEntityExtensions on FileEntity { return RevisionAction.hide; } else if (isHidden == false && previousRevision.isHidden.value == true) { return RevisionAction.unhide; + } else if (jsonEncode(thumbnail?.toJson()) != + previousRevision.thumbnail.value) { + return RevisionAction.rename; } return null; diff --git a/lib/models/tables/file_entries.drift b/lib/models/tables/file_entries.drift index 9dde437fdd..996a1f5f11 100644 --- a/lib/models/tables/file_entries.drift +++ b/lib/models/tables/file_entries.drift @@ -16,6 +16,8 @@ CREATE TABLE file_entries ( bundledIn TEXT, + thumbnail TEXT, + pinnedDataOwnerAddress TEXT, customJsonMetadata TEXT, diff --git a/lib/models/tables/file_revisions.drift b/lib/models/tables/file_revisions.drift index df13ec3dd1..c4533d9dbf 100644 --- a/lib/models/tables/file_revisions.drift +++ b/lib/models/tables/file_revisions.drift @@ -15,6 +15,7 @@ CREATE TABLE file_revisions ( dataTxId TEXT NOT NULL, licenseTxId TEXT, + thumbnail TEXT, bundledIn TEXT, diff --git a/lib/pages/drive_detail/components/drive_detail_data_list.dart b/lib/pages/drive_detail/components/drive_detail_data_list.dart index 5b9f7b8d67..a5c0fb6b9d 100644 --- a/lib/pages/drive_detail/components/drive_detail_data_list.dart +++ b/lib/pages/drive_detail/components/drive_detail_data_list.dart @@ -97,6 +97,7 @@ class FileDataTableItem extends ArDriveDataTableItem { final NetworkTransaction? metadataTx; final NetworkTransaction? dataTx; final String? pinnedDataOwnerAddress; + final Thumbnail? thumbnail; FileDataTableItem( {required super.driveId, @@ -116,6 +117,7 @@ class FileDataTableItem extends ArDriveDataTableItem { required this.metadataTx, required this.dataTx, required this.pinnedDataOwnerAddress, + this.thumbnail, super.licenseType, this.licenseTxId, this.bundledIn}) @@ -156,36 +158,41 @@ Widget _buildDataListContent( index: 0, canHide: false, ), - if (constraints.maxWidth > 500) - TableColumn( - appLocalizationsOf(context).size, - 3, - index: 1, - canHide: false, - ), - if (constraints.maxWidth > 640) - TableColumn( - appLocalizationsOf(context).lastUpdated, - 3, - index: 2, - isVisible: columnVisibility[2] ?? true, - ), - if (constraints.maxWidth > 700) - TableColumn( - appLocalizationsOf(context).dateCreated, - 3, - index: 3, - isVisible: columnVisibility[3] ?? true, - ), - if (constraints.maxWidth > 820) - TableColumn( - // TODO: Localize - // appLocalizationsOf(context).licenseType, - 'License', - 2, - index: 4, - isVisible: columnVisibility[4] ?? true, - ), + // if (constraints.maxWidth > 500) + TableColumn( + appLocalizationsOf(context).size, + 3, + index: 1, + canHide: false, + isVisible: + (constraints.maxWidth > 500 && (columnVisibility[1] ?? true)), + ), + // if (constraints.maxWidth > 640) + TableColumn( + appLocalizationsOf(context).lastUpdated, + 3, + index: 2, + isVisible: + (constraints.maxWidth > 640 && (columnVisibility[2] ?? true)), + ), + // if (constraints.maxWidth > 700) + TableColumn( + appLocalizationsOf(context).dateCreated, + 3, + index: 3, + isVisible: + (constraints.maxWidth > 700 && (columnVisibility[3] ?? true)), + ), + // if (constraints.maxWidth > 820) + TableColumn( + // TODO: Localize + // appLocalizationsOf(context).licenseType, + 'License', + 2, + index: 4, + isVisible: + (constraints.maxWidth > 820 && (columnVisibility[4] ?? true)), + ), ]; final driveDetailCubitState = context.read().state; @@ -272,8 +279,10 @@ Widget _buildDataListContent( return folders + files; }, buildRow: (row) { + final typography = ArDriveTypographyNew.of(context); return DriveExplorerItemTile( name: row.name, + typography: typography, size: row.size == null ? '-' : filesize(row.size), lastUpdated: yMMdDateFormatter.format(row.lastUpdated), dateCreated: yMMdDateFormatter.format(row.dateCreated), @@ -404,6 +413,9 @@ class DriveDataTableItemMapper { index: index, pinnedDataOwnerAddress: file.pinnedDataOwnerAddress, isHidden: file.isHidden, + thumbnail: file.thumbnail != null && file.thumbnail != 'null' + ? Thumbnail.fromJson(jsonDecode(file.thumbnail!)) + : null, ); } @@ -430,6 +442,9 @@ class DriveDataTableItemMapper { index: 0, pinnedDataOwnerAddress: fileEntry.pinnedDataOwnerAddress, isHidden: fileEntry.isHidden, + thumbnail: fileEntry.thumbnail != null + ? Thumbnail.fromJson(jsonDecode(fileEntry.thumbnail!)) + : null, ); } @@ -494,6 +509,9 @@ class DriveDataTableItemMapper { index: 0, pinnedDataOwnerAddress: revision.pinnedDataOwnerAddress, isHidden: revision.isHidden, + thumbnail: revision.thumbnail != null + ? Thumbnail.fromJson(jsonDecode(revision.thumbnail!)) + : null, ); } } 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 f21a3e83f5..19483c5a2f 100644 --- a/lib/pages/drive_detail/components/drive_explorer_item_tile.dart +++ b/lib/pages/drive_detail/components/drive_explorer_item_tile.dart @@ -1,3 +1,4 @@ +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'; @@ -6,16 +7,25 @@ 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'; import 'package:ardrive/models/models.dart'; 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/file_type_helper.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'; @@ -28,32 +38,39 @@ class DriveExplorerItemTile extends TableRowWidget { required String license, required Function() onPressed, required bool isHidden, + required ArdriveTypographyNew typography, }) : super( [ Padding( padding: const EdgeInsets.only(right: 8), child: Text( name, - style: ArDriveTypography.body.buttonNormalBold().copyWith( - color: isHidden ? Colors.grey : null, - ), + style: typography.paragraphNormal( + color: isHidden ? Colors.grey : null, + fontWeight: ArFontWeight.bold, + ), overflow: TextOverflow.fade, maxLines: 1, softWrap: false, ), ), - Text(size, style: _driveExplorerItemTileTextStyle(isHidden)), - Text(lastUpdated, style: _driveExplorerItemTileTextStyle(isHidden)), - Text(dateCreated, style: _driveExplorerItemTileTextStyle(isHidden)), + Text(size, + style: _driveExplorerItemTileTextStyle(isHidden, typography)), + Text(lastUpdated, + style: _driveExplorerItemTileTextStyle(isHidden, typography)), + Text(dateCreated, + style: _driveExplorerItemTileTextStyle(isHidden, typography)), Text(license, style: ArDriveTypography.body.captionRegular()), ], ); } -TextStyle _driveExplorerItemTileTextStyle(bool isHidden) => - ArDriveTypography.body - .captionRegular() - .copyWith(color: isHidden ? Colors.grey : null); +TextStyle _driveExplorerItemTileTextStyle( + bool isHidden, ArdriveTypographyNew typography) => + typography.paragraphNormal( + color: isHidden ? Colors.grey : null, + fontWeight: ArFontWeight.bold, + ); class DriveExplorerItemTileLeading extends StatelessWidget { const DriveExplorerItemTileLeading({ @@ -74,6 +91,108 @@ class DriveExplorerItemTileLeading extends StatelessWidget { } Widget _buildFileIcon(BuildContext context) { + if (item is FileDataTableItem && FileTypeHelper.isImage(item.contentType)) { + final file = item as FileDataTableItem; + + return ArDriveCard( + width: 30, + height: 30, + elevation: 0, + contentPadding: EdgeInsets.zero, + content: Stack( + 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( + GetThumbnail(fileDataTableItem: file), + ), + child: BlocBuilder( + builder: (context, state) { + if (state is ThumbnailLoading) { + return const SizedBox(); + } + + if (state is ThumbnailLoaded) { + if (state.thumbnail.url != null) { + return Align( + alignment: Alignment.center, + child: Image.network( + state.thumbnail.url!, + width: 30, + height: 30, + filterQuality: FilterQuality.high, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return getIconForContentType( + item.contentType, + ).copyWith( + color: isHidden ? Colors.grey : null, + ); + }, + ), + ); + } + + return Align( + alignment: Alignment.center, + child: Image.memory( + state.thumbnail.data!, + width: 30, + height: 30, + filterQuality: FilterQuality.low, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + logger.d('Error loading thumbnail: $error'); + return getIconForContentType( + item.contentType, + ).copyWith( + color: isHidden ? Colors.grey : null, + ); + }, + ), + ); + } + + return Align( + alignment: Alignment.center, + child: getIconForContentType( + item.contentType, + ).copyWith( + color: isHidden ? Colors.grey : null, + ), + ); + }, + ), + ), + if (item.fileStatusFromTransactions != null) + Positioned( + right: 3, + bottom: 3, + child: _buildFileStatus(context), + ), + ], + ), + backgroundColor: ArDriveTheme.of(context).themeData.backgroundColor, + ); + } + return ArDriveCard( width: 30, height: 30, @@ -83,10 +202,17 @@ class DriveExplorerItemTileLeading extends StatelessWidget { children: [ Align( alignment: Alignment.center, - child: getIconForContentType( - item.contentType, - ).copyWith( - color: isHidden ? Colors.grey : null, + child: GestureDetector( + onTap: () { + context + .read() + .uploadThumbnail(fileId: item.id); + }, + child: getIconForContentType( + item.contentType, + ).copyWith( + color: isHidden ? Colors.grey : null, + ), ), ), if (item.fileStatusFromTransactions != null) diff --git a/lib/pages/drive_detail/components/file_icon.dart b/lib/pages/drive_detail/components/file_icon.dart index 98e2c25e79..43f5b4e807 100644 --- a/lib/pages/drive_detail/components/file_icon.dart +++ b/lib/pages/drive_detail/components/file_icon.dart @@ -1,6 +1,6 @@ import 'package:ardrive/models/models.dart'; -import 'package:ardrive/utils/file_type_helper.dart'; import 'package:ardrive_ui/ardrive_ui.dart'; +import 'package:ardrive_utils/ardrive_utils.dart'; import 'package:flutter/material.dart'; class ArDriveFileIcon extends StatelessWidget { diff --git a/lib/pages/drive_detail/components/hover_widget.dart b/lib/pages/drive_detail/components/hover_widget.dart index e805ed7652..6f7c62ab5e 100644 --- a/lib/pages/drive_detail/components/hover_widget.dart +++ b/lib/pages/drive_detail/components/hover_widget.dart @@ -99,12 +99,14 @@ class ArDriveIconButton extends StatelessWidget { this.onPressed, this.size = 16, this.tooltip, + this.scale, }); final ArDriveIcon icon; final Function()? onPressed; final double size; final String? tooltip; + final bool? scale; @override Widget build(BuildContext context) { @@ -112,6 +114,7 @@ class ArDriveIconButton extends StatelessWidget { onTap: onPressed, child: HoverWidget( tooltip: tooltip, + hoverScale: scale == true ? 1.1 : 1.0, child: icon, ), ); diff --git a/lib/pages/drive_detail/drive_detail_page.dart b/lib/pages/drive_detail/drive_detail_page.dart index 6c9294d152..1990a1e1a1 100644 --- a/lib/pages/drive_detail/drive_detail_page.dart +++ b/lib/pages/drive_detail/drive_detail_page.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:math'; import 'package:ardrive/app_shell.dart'; @@ -17,13 +18,19 @@ import 'package:ardrive/components/details_panel.dart'; import 'package:ardrive/components/drive_detach_dialog.dart'; import 'package:ardrive/components/drive_rename_form.dart'; import 'package:ardrive/components/fs_entry_license_form.dart'; +import 'package:ardrive/components/keyboard_handler.dart'; import 'package:ardrive/components/new_button/new_button.dart'; import 'package:ardrive/components/pin_file_dialog.dart'; import 'package:ardrive/components/prompt_to_snapshot_dialog.dart'; 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'; import 'package:ardrive/misc/resources.dart'; import 'package:ardrive/models/license.dart'; @@ -42,6 +49,7 @@ 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'; @@ -53,6 +61,7 @@ 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'; @@ -146,111 +155,156 @@ class _DriveDetailPageState extends State { } } }, - child: BlocBuilder( - builder: (context, driveDetailState) { - if (driveDetailState is DriveDetailLoadInProgress) { - return const Center(child: CircularProgressIndicator()); - } else if (driveDetailState is DriveInitialLoading) { - return 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(), - ), - ), + 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); + }, ), - ); - }, - desktop: (context) => Scaffold( - drawerScrimColor: Colors.transparent, - body: Column( - children: [ - const AppTopBar(), - Expanded( - child: Center( - child: Text( - appLocalizationsOf(context) - .driveDoingInitialSetupMessage, - style: ArDriveTypography.body.buttonLargeBold(), + ], + 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) + .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 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(); + } + }, + ), ), ), ), diff --git a/lib/utils/file_type_helper.dart b/lib/utils/file_type_helper.dart index ed42d24301..e69de29bb2 100644 --- a/lib/utils/file_type_helper.dart +++ b/lib/utils/file_type_helper.dart @@ -1,70 +0,0 @@ -abstract class FileTypeHelper { - static const List _imageTypes = ['image/']; - static const List _audioTypes = ['audio/']; - static const List _videoTypes = ['video/']; - - static const List _docTypes = [ - 'text/', - 'application/msword', - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'application/pdf', - ]; - - static const List _codeTypes = [ - 'text/html', - 'text/css', - 'text/javascript', - 'application/javascript', - 'application/json', - 'application/xml', - 'application/xhtml+xml', - 'text/x-c++src', - 'text/x-csrc', - 'text/x-diff', - 'text/x-go', - 'text/x-java', - 'text/x-kotlin', - 'text/x-markdown', - 'text/x-perl', - 'text/x-python', - 'text/x-rustsrc', - 'text/x-swift', - ]; - - static const List _zipTypes = [ - 'application/zip', - 'application/x-rar-compressed' - ]; - - static const List _manifestTypes = [ - 'application/x.arweave-manifest+json', - ]; - - static bool isImage(String contentType) { - return _imageTypes.any((type) => contentType.startsWith(type)); - } - - static bool isAudio(String contentType) { - return _audioTypes.any((type) => contentType.startsWith(type)); - } - - static bool isVideo(String contentType) { - return _videoTypes.any((type) => contentType.startsWith(type)); - } - - static bool isDoc(String contentType) { - return _docTypes.contains(contentType) || contentType.startsWith('text/'); - } - - static bool isCode(String contentType) { - return _codeTypes.any((type) => contentType.startsWith(type)); - } - - static bool isZip(String contentType) { - return _zipTypes.contains(contentType); - } - - static bool isManifest(String contentType) { - return _manifestTypes.contains(contentType); - } -} diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index dcb6ac3b90..1b25475c4a 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -13,7 +13,7 @@ import file_saver import file_selector_macos import firebase_core import firebase_crashlytics -import flutter_inappwebview_macos +import flutter_image_compress_macos import flutter_secure_storage_macos import just_audio import package_info_plus @@ -36,7 +36,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) FLTFirebaseCrashlyticsPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCrashlyticsPlugin")) - InAppWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "InAppWebViewFlutterPlugin")) + FlutterImageCompressMacosPlugin.register(with: registry.registrar(forPlugin: "FlutterImageCompressMacosPlugin")) FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) diff --git a/packages/ardrive_ui/pubspec.yaml b/packages/ardrive_ui/pubspec.yaml index e8ee4c49e4..16dc5f6a55 100644 --- a/packages/ardrive_ui/pubspec.yaml +++ b/packages/ardrive_ui/pubspec.yaml @@ -23,7 +23,7 @@ dependencies: flutter_portal: ^1.1.2 flutter_svg: ^2.0.10+1 percent_indicator: ^4.2.2 - flutter_svg_image: ^1.1.0+1 + flutter_svg_image: provider: ^6.0.5 equatable: ^2.0.5 diff --git a/packages/ardrive_uploader/example/macos/Flutter/GeneratedPluginRegistrant.swift b/packages/ardrive_uploader/example/macos/Flutter/GeneratedPluginRegistrant.swift index dd517499b2..5ea29635ed 100644 --- a/packages/ardrive_uploader/example/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/packages/ardrive_uploader/example/macos/Flutter/GeneratedPluginRegistrant.swift @@ -8,6 +8,7 @@ import Foundation import device_info_plus import file_saver import file_selector_macos +import flutter_image_compress_macos import package_info_plus import path_provider_foundation import sentry_flutter @@ -18,6 +19,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) FileSaverPlugin.register(with: registry.registrar(forPlugin: "FileSaverPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) + FlutterImageCompressMacosPlugin.register(with: registry.registrar(forPlugin: "FlutterImageCompressMacosPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SentryFlutterPlugin.register(with: registry.registrar(forPlugin: "SentryFlutterPlugin")) diff --git a/packages/ardrive_uploader/lib/src/ardrive_uploader.dart b/packages/ardrive_uploader/lib/src/ardrive_uploader.dart index 64a5a1b3a5..0b6fe6a371 100644 --- a/packages/ardrive_uploader/lib/src/ardrive_uploader.dart +++ b/packages/ardrive_uploader/lib/src/ardrive_uploader.dart @@ -8,6 +8,7 @@ import 'package:ardrive_utils/ardrive_utils.dart'; import 'package:arweave/arweave.dart'; import 'package:cryptography/cryptography.dart' hide Cipher; import 'package:pst/pst.dart'; +import 'package:uuid/uuid.dart'; enum UploadType { turbo, d2n } @@ -18,6 +19,7 @@ abstract class ArDriveUploader { required Wallet wallet, SecretKey? driveKey, required UploadType type, + bool uploadThumbnail = true, }) { throw UnimplementedError(); } @@ -27,6 +29,7 @@ abstract class ArDriveUploader { required Wallet wallet, SecretKey? driveKey, required UploadType type, + bool uploadThumbnail = true, }) { throw UnimplementedError(); } @@ -38,6 +41,17 @@ abstract class ArDriveUploader { Function(ARFSUploadMetadata)? skipMetadataUpload, Function(ARFSUploadMetadata)? onCreateMetadata, required UploadType type, + bool uploadThumbnail = true, + }) { + throw UnimplementedError(); + } + + Future uploadThumbnail({ + required IOFile file, + required Wallet wallet, + required UploadType type, + required ThumbnailUploadMetadata thumbnailMetadata, + SecretKey? fileKey, }) { throw UnimplementedError(); } @@ -106,6 +120,7 @@ class _ArDriveUploader implements ArDriveUploader { required Wallet wallet, SecretKey? driveKey, required UploadType type, + bool uploadThumbnail = true, }) async { final dataBundler = _dataBundlerFactory.createDataBundler( type, @@ -116,6 +131,10 @@ class _ArDriveUploader implements ArDriveUploader { streamedUploadFactory: _streamedUploadFactory, ); + final thumbnailStrategy = UploadThumbnailStrategy( + streamedUploadFactory: _streamedUploadFactory, + dataBundler: dataBundler); + final uploadController = UploadController( StreamController(), UploadDispatcher( @@ -123,6 +142,7 @@ class _ArDriveUploader implements ArDriveUploader { uploadStrategy: _uploadFileStrategyFactory.createUploadStrategy( type: type, ), + uploadThumbnailStrategy: thumbnailStrategy, uploadFolderStrategy: uploadFolderStrategy, ), numOfWorkers: 1, @@ -140,6 +160,8 @@ class _ArDriveUploader implements ArDriveUploader { content: [metadata], encryptionKey: driveKey, type: type, + uploadThumbnail: + FileTypeHelper.isImage(file.contentType) && uploadThumbnail, ); uploadController.addTask(uploadTask); @@ -154,6 +176,7 @@ class _ArDriveUploader implements ArDriveUploader { required List<(ARFSUploadMetadataArgs, IOFile)> files, required Wallet wallet, SecretKey? driveKey, + bool uploadThumbnail = true, required UploadType type, }) async { logger.i('Creating a new upload controller using the upload type $type'); @@ -174,13 +197,16 @@ class _ArDriveUploader implements ArDriveUploader { dataBundler: dataBundler, uploadStrategy: uploadFileStrategy, uploadFolderStrategy: uploadFolderStrategy, + uploadThumbnailStrategy: UploadThumbnailStrategy( + streamedUploadFactory: _streamedUploadFactory, + dataBundler: dataBundler), ); final uploadController = UploadController( StreamController(), uploadSender, - numOfWorkers: driveKey != null ? 3 : 5, - maxTasksPerWorker: driveKey != null ? 1 : 3, + numOfWorkers: driveKey != null ? 2 : 5, + maxTasksPerWorker: 3, ); for (var f in files) { @@ -198,6 +224,8 @@ class _ArDriveUploader implements ArDriveUploader { content: [metadata], encryptionKey: driveKey, type: type, + uploadThumbnail: + FileTypeHelper.isImage(f.$2.contentType) && uploadThumbnail, ); uploadController.addTask(fileTask); @@ -219,6 +247,7 @@ class _ArDriveUploader implements ArDriveUploader { Function(ARFSUploadMetadata p1)? skipMetadataUpload, Function(ARFSUploadMetadata p1)? onCreateMetadata, UploadType type = UploadType.turbo, + bool uploadThumbnail = true, }) async { final dataBundler = _dataBundlerFactory.createDataBundler( type, @@ -236,6 +265,10 @@ class _ArDriveUploader implements ArDriveUploader { dataBundler: dataBundler, uploadStrategy: uploadStrategy, uploadFolderStrategy: uploadFolderStrategy, + uploadThumbnailStrategy: UploadThumbnailStrategy( + dataBundler: dataBundler, + streamedUploadFactory: _streamedUploadFactory, + ), ); final filesWitMetadatas = <(ARFSFileUploadMetadata, IOFile)>[]; @@ -282,6 +315,8 @@ class _ArDriveUploader implements ArDriveUploader { encryptionKey: driveKey, content: [f.$1], type: type, + uploadThumbnail: + FileTypeHelper.isImage(f.$2.contentType) && uploadThumbnail, ); uploadController.addTask(fileTask); @@ -308,14 +343,52 @@ class _ArDriveUploader implements ArDriveUploader { return uploadController; } -} -class DataResultWithContents { - final T dataItemResult; - final List contents; + @override + Future uploadThumbnail({ + required IOFile file, + required Wallet wallet, + required UploadType type, + required ThumbnailUploadMetadata thumbnailMetadata, + SecretKey? fileKey, + }) async { + final dataBundler = _dataBundlerFactory.createDataBundler( + type, + ); + + final UploadFileStrategy uploadFileStrategy = UploadFileUsingDataItemFiles( + streamedUploadFactory: _streamedUploadFactory); - DataResultWithContents({ - required this.dataItemResult, - required this.contents, - }); + final uploadController = UploadController( + StreamController(), + UploadDispatcher( + dataBundler: dataBundler, + uploadStrategy: uploadFileStrategy, + uploadFolderStrategy: UploadFolderStructureAsBundleStrategy( + dataBundler: dataBundler, + streamedUploadFactory: _streamedUploadFactory, + ), + uploadThumbnailStrategy: UploadThumbnailStrategy( + streamedUploadFactory: _streamedUploadFactory, + dataBundler: _dataBundlerFactory.createDataBundler(type), + ), + ), + numOfWorkers: 1, + maxTasksPerWorker: 1, + ); + + final uploadTask = ThumbnailUploadTask( + file: file, + metadata: thumbnailMetadata, + type: type, + id: Uuid().v4(), + encryptionKey: fileKey, + ); + + uploadController.addTask(uploadTask); + + uploadController.sendTasks(wallet); + + return uploadController; + } } diff --git a/packages/ardrive_uploader/lib/src/arfs_upload_metadata.dart b/packages/ardrive_uploader/lib/src/arfs_upload_metadata.dart index 449698abad..e4824f251c 100644 --- a/packages/ardrive_uploader/lib/src/arfs_upload_metadata.dart +++ b/packages/ardrive_uploader/lib/src/arfs_upload_metadata.dart @@ -3,6 +3,65 @@ import 'package:arweave/arweave.dart'; abstract class UploadMetadata {} +class ThumbnailUploadMetadata extends UploadMetadata { + ThumbnailUploadMetadata({ + required this.size, + required this.relatesTo, + required this.height, + required this.width, + required this.name, + required this.contentType, + required this.originalFileId, + }); + + List thumbnailTags() { + final tags = [ + Tag('Relates-To', relatesTo), + Tag(EntityTag.contentType, contentType), + Tag('Width', width.toString()), + Tag('Height', height.toString()), + Tag('Version', '1.0'), + if (_cipherTag != null) Tag(EntityTag.cipher, _cipherTag!), + if (_cipherIvTag != null) Tag(EntityTag.cipherIv, _cipherIvTag!), + ]; + + return tags; + } + + final String relatesTo; + final int size; + final int height; + final int width; + final String name; + final String contentType; + final String originalFileId; + String? _txId; + String? _cipherTag; + String? _cipherIvTag; + + set setTxId(String txId) => _txId = txId; + + setCipherTags({ + required String cipherTag, + required String cipherIvTag, + }) { + _cipherTag = cipherTag; + _cipherIvTag = cipherIvTag; + } + + get txId => _txId; + + Map toJson() { + return { + 'name': name, + 'txId': _txId, + 'size': size, + 'height': height, + 'width': width, + }; + } +} + class ARFSDriveUploadMetadata extends ARFSUploadMetadata { ARFSDriveUploadMetadata({ required super.name, @@ -139,11 +198,21 @@ class ARFSFileUploadMetadata extends ARFSUploadMetadata with ARFSUploadData { // Getter for licenseTxId String? get licenseTxId => _licenseTxId; + /// Additional Thumbnail tags for the file. + List? _thumbnailInfo; + + // Getter for thumbnailTxId + List? get thumbnailInfo => _thumbnailInfo; + // Public method to set licenseTxId with validation or additional logic void updateLicenseTxId(String licenseTxId) { _licenseTxId = licenseTxId; } + void updateThumbnailInfo(List thumbnailInfo) { + _thumbnailInfo = thumbnailInfo; + } + @override Map toJson() { if (dataTxId == null) { @@ -156,6 +225,12 @@ class ARFSFileUploadMetadata extends ARFSUploadMetadata with ARFSUploadData { 'lastModifiedDate': lastModifiedDate.millisecondsSinceEpoch, 'dataContentType': dataContentType, 'dataTxId': dataTxId, + if (_thumbnailInfo != null) + 'thumbnail': { + 'variants': [ + for (var variant in _thumbnailInfo!) variant.toJson(), + ], + }, if (licenseTxId != null) 'licenseTxId': licenseTxId, }; } diff --git a/packages/ardrive_uploader/lib/src/data_bundler.dart b/packages/ardrive_uploader/lib/src/data_bundler.dart index 0831b155cc..df98024ed9 100644 --- a/packages/ardrive_uploader/lib/src/data_bundler.dart +++ b/packages/ardrive_uploader/lib/src/data_bundler.dart @@ -17,17 +17,6 @@ import 'package:pst/pst.dart'; import 'package:uuid/uuid.dart'; abstract class DataBundler { - Future> createDataItemsForFile({ - required IOFile file, - required ARFSFileUploadMetadata metadata, - required Wallet wallet, - SecretKey? driveKey, - Function? onStartMetadataCreation, - Function? onFinishMetadataCreation, - Function? onStartBundleCreation, - Function? onFinishBundleCreation, - }); - Future createDataBundle({ required IOFile file, required ARFSFileUploadMetadata metadata, @@ -75,17 +64,17 @@ class DataTransactionBundler implements DataBundler { Function? onStartBundleCreation, Function? onFinishBundleCreation, }) async { - dataItemFiles ??= await createDataItemsForFile( + final uploadPreparation = await prepareDataItems( file: file, metadata: metadata, wallet: wallet, driveKey: driveKey, onStartMetadataCreation: onStartMetadataCreation, onFinishMetadataCreation: onFinishMetadataCreation, - onStartBundleCreation: onStartBundleCreation, - onFinishBundleCreation: onFinishBundleCreation, ); + dataItemFiles ??= uploadPreparation.dataItemFiles; + onStartBundleCreation?.call(); final transactionResult = await createDataBundleTransaction( @@ -221,59 +210,6 @@ class DataTransactionBundler implements DataBundler { (r) => r, ).run(); } - - @override - Future> createDataItemsForFile({ - required IOFile file, - required ARFSFileUploadMetadata metadata, - required Wallet wallet, - SecretKey? driveKey, - Function? onStartMetadataCreation, - Function? onFinishMetadataCreation, - Function? onStartBundleCreation, - Function? onFinishBundleCreation, - }) async { - SecretKey? key; - - if (driveKey != null) { - key = await deriveFileKey( - driveKey, - metadata.id, - keyByteLength, - ); - // onStartEncryption?.call(); - } else { - // onStartBundling?.call(); - } - - // returns the encrypted or not file read stream and the cipherIv if it was encrypted - final dataGenerator = await _dataGenerator( - dataStream: file.openReadStream, - fileLength: await file.length, - metadata: metadata, - wallet: wallet, - encryptionKey: key, - ); - - onStartMetadataCreation?.call(); - - final metadataDataItem = await _generateMetadataDataItemForFile( - metadata: metadata, - dataStream: dataGenerator, - wallet: wallet, - driveKey: driveKey, - ); - - final fileDataItem = _generateFileDataItem( - metadata: metadata, - dataStream: dataGenerator.$1, - fileLength: dataGenerator.$4, - ); - - onFinishMetadataCreation?.call(); - - return [metadataDataItem, fileDataItem]; - } } class BDIDataBundler implements DataBundler { @@ -320,15 +256,13 @@ class BDIDataBundler implements DataBundler { Function? onStartBundleCreation, Function? onFinishBundleCreation, }) async { - final dataItemsFiles = await createDataItemsForFile( + final preparation = await prepareDataItems( file: file, metadata: metadata, wallet: wallet, driveKey: driveKey, onStartMetadataCreation: onStartMetadataCreation, onFinishMetadataCreation: onFinishMetadataCreation, - onStartBundleCreation: onStartBundleCreation, - onFinishBundleCreation: onFinishBundleCreation, ); onStartBundleCreation?.call(); @@ -339,7 +273,7 @@ class BDIDataBundler implements DataBundler { ); final createBundledDataItem = createBundledDataItemTaskEither( - dataItemFiles: dataItemsFiles, + dataItemFiles: preparation.dataItemFiles, wallet: wallet, tags: bundleTags.map((e) => createTag(e.name, e.value)).toList(), ); @@ -429,62 +363,9 @@ class BDIDataBundler implements DataBundler { ...dataItemsResult ]; } - - @override - Future> createDataItemsForFile({ - required IOFile file, - required ARFSFileUploadMetadata metadata, - required Wallet wallet, - SecretKey? driveKey, - Function? onStartMetadataCreation, - Function? onFinishMetadataCreation, - Function? onStartBundleCreation, - Function? onFinishBundleCreation, - }) async { - onStartMetadataCreation?.call(); - - SecretKeyData? key; - - if (driveKey != null) { - key = await deriveFileKey( - driveKey, - metadata.id, - keyByteLength, - ); - } - - // returns the encrypted or not file read stream and the cipherIv if it was encrypted - final dataGenerator = await _dataGenerator( - dataStream: file.openReadStream, - fileLength: await file.length, - metadata: metadata, - wallet: wallet, - encryptionKey: key, - ); - - final metadataDataItem = await _generateMetadataDataItemForFile( - metadata: metadata, - dataStream: dataGenerator, - wallet: wallet, - driveKey: driveKey, - ); - - onFinishMetadataCreation?.call(); - - final fileDataItem = _generateFileDataItem( - metadata: metadata, - dataStream: dataGenerator.$1, - fileLength: dataGenerator.$4, - ); - - logger.d('Metadata tags length: ${metadataDataItem.tags.length}'); - logger.d('DataItem tags length: ${fileDataItem.tags.length}'); - - return [metadataDataItem, fileDataItem]; - } } -DataItemFile _generateFileDataItem({ +DataItemFile _generateDataDataItem({ required ARFSFileUploadMetadata metadata, required Stream Function() dataStream, required int fileLength, @@ -575,49 +456,16 @@ Future _generateMetadataDataItem({ ); } -Future _generateMetadataDataItemForFile({ +Future _generateFileMetadataDataItem({ required ARFSFileUploadMetadata metadata, - required ( - Stream Function(), - Uint8List? dataStream, - String? cipher, - int fileLength - ) dataStream, + required DataItemResult dataItemResult, required Wallet wallet, SecretKey? driveKey, }) async { - if (driveKey != null) { - final cipher = dataStream.$3; - final cipherIv = dataStream.$2; - - metadata.setDataCipher( - cipher: cipher!, cipherIv: encodeBytesToBase64(cipherIv!)); + if (metadata.licenseDefinitionTxId != null) { + metadata.updateLicenseTxId(dataItemResult.id); } - final dataStreamGenerator = dataStream.$1; - final dataStreamSize = dataStream.$4; - - logger.d('Data tags: ${getJsonFromListOfTags(metadata.getDataTags())}'); - - final fileDataItemEither = createDataItemTaskEither( - wallet: wallet, - dataStream: dataStreamGenerator, - dataStreamSize: dataStreamSize, - tags: - metadata.getDataTags().map((e) => createTag(e.name, e.value)).toList(), - ); - - final fileDataItemResult = await fileDataItemEither.run(); - - fileDataItemResult.match((l) { - throw l; - }, (fileDataItem) { - metadata.updateDataTxId(fileDataItem.id); - if (metadata.licenseDefinitionTxId != null) { - metadata.updateLicenseTxId(fileDataItem.id); - } - }); - int metadataLength; final metadataBytes = utf8 @@ -756,7 +604,7 @@ Future< String? cipher, int fileSize )> _dataGenerator({ - required ARFSUploadMetadata metadata, + required String metadataId, required Stream Function() dataStream, required int fileLength, required Wallet wallet, @@ -764,7 +612,7 @@ Future< }) async { if (encryptionKey != null) { return await handleEncryption( - encryptionKey, dataStream, metadata.id, fileLength, keyByteLength); + encryptionKey, dataStream, metadataId, fileLength, keyByteLength); } else { return ( dataStream, @@ -774,3 +622,216 @@ Future< ); } } + +class DataResultWithContents { + final T dataItemResult; + final List contents; + + DataResultWithContents({ + required this.dataItemResult, + required this.contents, + }); +} + +Future prepareDataItems({ + required IOFile file, + required ARFSFileUploadMetadata metadata, + required Wallet wallet, + SecretKey? driveKey, + Function? onStartMetadataCreation, + Function? onFinishMetadataCreation, + + /// pass down to the thumbnail creation + bool addThumbnail = true, +}) async { + SecretKey? key; + + if (driveKey != null) { + key = await deriveFileKey( + driveKey, + metadata.id, + keyByteLength, + ); + } + + // returns the encrypted or not file read stream and the cipherIv if it was encrypted + final dataGenerator = await _dataGenerator( + dataStream: file.openReadStream, + fileLength: await file.length, + metadataId: metadata.id, + wallet: wallet, + encryptionKey: key, + ); + + /// Gets the Data DataItem result + final dataDataItemResult = await _getDataItemResult( + wallet: wallet, + metadata: metadata, + dataStream: dataGenerator, + fileLength: dataGenerator.$4, + driveKey: driveKey, + ); + + DataItemResult? thumbnailDataItem; + IOFile? thumbnailFile; + + /// Thumbnail generation + if (addThumbnail) { + final thumbnailGenerationResult = await generateThumbnail( + await file.readAsBytes(), + ThumbnailSize.small, + ); + + thumbnailFile = await IOFile.fromData( + thumbnailGenerationResult.thumbnail, + name: 'thumbnail', + lastModifiedDate: DateTime.now(), + contentType: 'image/jpeg', + ); + + final thumbnailMetadata = ThumbnailUploadMetadata( + height: thumbnailGenerationResult.height, + width: thumbnailGenerationResult.width, + size: thumbnailGenerationResult.thumbnail.length, + name: thumbnailGenerationResult.name, + relatesTo: metadata.dataTxId!, + contentType: 'image/jpeg', + originalFileId: metadata.id, + ); + + thumbnailDataItem = await createDataItemForThumbnail( + file: thumbnailFile, + metadata: thumbnailMetadata, + wallet: wallet, + encryptionKey: key, + fileId: metadata.id, + ); + + thumbnailMetadata.setTxId = thumbnailDataItem.id; + + /// needs to be `variants` + metadata.updateThumbnailInfo([thumbnailMetadata]); + } + + onStartMetadataCreation?.call(); + + final metadataDataItem = await _generateFileMetadataDataItem( + metadata: metadata, + dataItemResult: dataDataItemResult, + wallet: wallet, + driveKey: driveKey, + ); + + final dataDataItem = _generateDataDataItem( + metadata: metadata, + dataStream: dataGenerator.$1, + fileLength: dataGenerator.$4, + ); + + onFinishMetadataCreation?.call(); + + return UploadFilePreparation( + dataItemFiles: [metadataDataItem, dataDataItem], + thumbnailFile: thumbnailFile, + thumbnailDataItem: thumbnailDataItem, + ); +} + +class UploadFilePreparation { + final List dataItemFiles; + final IOFile? thumbnailFile; + final DataItemResult? thumbnailDataItem; + + UploadFilePreparation({ + required this.dataItemFiles, + this.thumbnailFile, + this.thumbnailDataItem, + }); +} + +Future _getDataItemResult({ + required Wallet wallet, + required ARFSFileUploadMetadata metadata, + required ( + Stream Function(), + Uint8List? dataStream, + String? cipher, + int fileLength + ) dataStream, + required int fileLength, + required SecretKey? driveKey, +}) async { + if (driveKey != null) { + final cipher = dataStream.$3; + final cipherIv = dataStream.$2; + + metadata.setDataCipher( + cipher: cipher!, cipherIv: encodeBytesToBase64(cipherIv!)); + } + + final dataStreamGenerator = dataStream.$1; + final dataStreamSize = dataStream.$4; + + logger.d('Data tags: ${getJsonFromListOfTags(metadata.getDataTags())}'); + + final fileDataItemEither = createDataItemTaskEither( + wallet: wallet, + dataStream: dataStreamGenerator, + dataStreamSize: dataStreamSize, + tags: + metadata.getDataTags().map((e) => createTag(e.name, e.value)).toList(), + ); + + final fileDataItemResult = await fileDataItemEither.run(); + + return fileDataItemResult.match((l) { + throw l; + }, (r) { + metadata.updateDataTxId(r.id); + + return r; + }); +} + +@override +Future createDataItemForThumbnail({ + required IOFile file, + required ThumbnailUploadMetadata metadata, + required Wallet wallet, + SecretKey? encryptionKey, + required String fileId, +}) async { + final dataGenerator = await _dataGenerator( + dataStream: file.openReadStream, + fileLength: metadata.size, + metadataId: fileId, + wallet: wallet, + encryptionKey: encryptionKey, + ); + + if (encryptionKey != null) { + final cipher = dataGenerator.$3; + final cipherIv = dataGenerator.$2; + + metadata.setCipherTags( + cipherTag: cipher!, cipherIvTag: encodeBytesToBase64(cipherIv!)); + } + + final taskEither = await createDataItemTaskEither( + wallet: wallet, + dataStream: dataGenerator.$1, + dataStreamSize: metadata.size, + tags: metadata + .thumbnailTags() + .map((e) => createTag(e.name, e.value)) + .toList(), + ).run(); + + return taskEither.match((l) { + throw l; + }, (r) { + metadata.setTxId = r.id; + + return r; + }); +} diff --git a/packages/ardrive_uploader/lib/src/exceptions.dart b/packages/ardrive_uploader/lib/src/exceptions.dart index 14f53b9a51..04b09ed78e 100644 --- a/packages/ardrive_uploader/lib/src/exceptions.dart +++ b/packages/ardrive_uploader/lib/src/exceptions.dart @@ -116,3 +116,15 @@ class UnknownNetworkException implements NetworkException { @override Object? error; } + +class ThumbnailUploadException implements UploadStrategyException { + ThumbnailUploadException({ + required this.message, + this.error, + }); + + @override + final String message; + @override + Object? error; +} diff --git a/packages/ardrive_uploader/lib/src/upload_controller.dart b/packages/ardrive_uploader/lib/src/upload_controller.dart index 2854175f68..74346ab345 100644 --- a/packages/ardrive_uploader/lib/src/upload_controller.dart +++ b/packages/ardrive_uploader/lib/src/upload_controller.dart @@ -525,6 +525,9 @@ enum UploadStatus { /// The upload has failed failed, + /// uploading thumbnail + uploadingThumbnail, + /// The upload has been canceled canceled, } diff --git a/packages/ardrive_uploader/lib/src/upload_dispatcher.dart b/packages/ardrive_uploader/lib/src/upload_dispatcher.dart index 158ab37c5d..d2b3a7865d 100644 --- a/packages/ardrive_uploader/lib/src/upload_dispatcher.dart +++ b/packages/ardrive_uploader/lib/src/upload_dispatcher.dart @@ -1,19 +1,24 @@ +import 'dart:async'; + import 'package:ardrive_uploader/ardrive_uploader.dart'; import 'package:ardrive_uploader/src/data_bundler.dart'; import 'package:ardrive_uploader/src/utils/logger.dart'; +import 'package:ardrive_utils/ardrive_utils.dart'; import 'package:arweave/arweave.dart'; +import 'package:uuid/uuid.dart'; class UploadDispatcher { UploadFileStrategy _uploadFileStrategy; final UploadFolderStructureStrategy _uploadFolderStrategy; - final DataBundler _dataBundler; + final UploadThumbnailStrategy _uploadThumbnailStrategy; UploadDispatcher({ required UploadFileStrategy uploadStrategy, required DataBundler dataBundler, required UploadFolderStructureStrategy uploadFolderStrategy, - }) : _dataBundler = dataBundler, - _uploadFolderStrategy = uploadFolderStrategy, + required UploadThumbnailStrategy uploadThumbnailStrategy, + }) : _uploadFolderStrategy = uploadFolderStrategy, + _uploadThumbnailStrategy = uploadThumbnailStrategy, _uploadFileStrategy = uploadStrategy; Future send({ @@ -24,18 +29,12 @@ class UploadDispatcher { }) async { try { if (task is FileUploadTask) { - final dataItems = await _dataBundler.createDataItemsForFile( + final uploadPreparation = await prepareDataItems( file: task.file, metadata: task.metadata, wallet: wallet, + addThumbnail: task.uploadThumbnail, driveKey: task.encryptionKey, - onStartBundleCreation: () { - controller.updateProgress( - task: task.copyWith( - status: UploadStatus.creatingBundle, - ), - ); - }, onStartMetadataCreation: () { controller.updateProgress( task: task.copyWith( @@ -49,12 +48,59 @@ class UploadDispatcher { 'Uploading task ${task.id} with strategy: ${_uploadFileStrategy.runtimeType}'); await _uploadFileStrategy.upload( - dataItems: dataItems, + dataItems: uploadPreparation.dataItemFiles, task: task, wallet: wallet, controller: controller, verifyCancel: verifyCancel, ); + + var updatedTask = controller.tasks[task.id]! as FileUploadTask; + + /// Verify supported extentions + if (FileTypeHelper.isImage(updatedTask.metadata.dataContentType) && + task.uploadThumbnail) { + try { + controller.updateProgress( + task: updatedTask.copyWith( + status: UploadStatus.uploadingThumbnail, + ), + ); + + final fileMetadata = updatedTask.metadata; + + final thumbnailMetadata = fileMetadata.thumbnailInfo?.first; + + final thumbnailTask = ThumbnailUploadTask( + file: uploadPreparation.thumbnailFile!, + metadata: thumbnailMetadata!, + type: task.type, + uploadItem: DataItemUploadItem( + size: uploadPreparation.thumbnailDataItem!.dataSize, + data: uploadPreparation.thumbnailDataItem!), + id: Uuid().v4(), + + /// same encryption key as the file + encryptionKey: task.encryptionKey, + ); + + await _uploadThumbnailStrategy.upload( + task: thumbnailTask, + wallet: wallet, + controller: UploadController(StreamController(), this), + verifyCancel: verifyCancel, + ); + } catch (e) { + logger.e('Error uploading thumbnail: $e'); + } + } + + updatedTask = controller.tasks[task.id]! as FileUploadTask; + + controller.updateProgress( + task: updatedTask.copyWith( + status: UploadStatus.complete, content: [updatedTask.metadata]), + ); } else if (task is FolderUploadTask) { await _uploadFolderStrategy.upload( task: task, @@ -62,6 +108,13 @@ class UploadDispatcher { controller: controller, verifyCancel: verifyCancel, ); + } else if (task is ThumbnailUploadTask) { + await _uploadThumbnailStrategy.upload( + task: task, + wallet: wallet, + controller: controller, + verifyCancel: verifyCancel, + ); } else { throw Exception('Invalid task type'); } diff --git a/packages/ardrive_uploader/lib/src/upload_strategy.dart b/packages/ardrive_uploader/lib/src/upload_strategy.dart index b515f8ae67..fd4f94b056 100644 --- a/packages/ardrive_uploader/lib/src/upload_strategy.dart +++ b/packages/ardrive_uploader/lib/src/upload_strategy.dart @@ -19,6 +19,25 @@ abstract class UploadFileStrategy { }); } +abstract class UploadThumbnailStrategy { + Future upload({ + required ThumbnailUploadTask task, + required Wallet wallet, + required UploadController controller, + required bool Function() verifyCancel, + }); + + factory UploadThumbnailStrategy({ + required StreamedUploadFactory streamedUploadFactory, + required DataBundler dataBundler, + }) { + return _UploadThumbnailStrategy( + streamedUploadFactory: streamedUploadFactory, + dataBundler: dataBundler, + ); + } +} + abstract class UploadFolderStructureStrategy { Future upload({ required FolderUploadTask task, @@ -165,14 +184,6 @@ class UploadFileUsingDataItemFiles extends UploadFileStrategy { error: result.error, ); } - - final updatedTask = controller.tasks[task.id]!; - - controller.updateProgress( - task: updatedTask.copyWith( - status: UploadStatus.complete, - ), - ); } } @@ -365,6 +376,67 @@ class UploadFolderStructureAsBundleStrategy } } +class _UploadThumbnailStrategy implements UploadThumbnailStrategy { + final StreamedUploadFactory _streamedUploadFactory; + + _UploadThumbnailStrategy({ + required StreamedUploadFactory streamedUploadFactory, + required DataBundler dataBundler, + }) : _streamedUploadFactory = streamedUploadFactory; + + @override + Future upload({ + required ThumbnailUploadTask task, + required Wallet wallet, + required UploadController controller, + required bool Function() verifyCancel, + }) async { + if (task.uploadItem == null) { + final thumbnailDataItem = await createDataItemForThumbnail( + file: task.file, + metadata: task.metadata, + wallet: wallet, + encryptionKey: task.encryptionKey, + fileId: task.metadata.originalFileId, + ); + + task = task.copyWith( + uploadItem: DataItemUploadItem( + size: thumbnailDataItem.dataItemSize, data: thumbnailDataItem)); + } + + /// It will always use the Turbo for now + + final streamedUpload = + _streamedUploadFactory.fromUploadType(UploadType.turbo); + + final result = await streamedUpload.send( + task.uploadItem!, + wallet, + (progress) { + controller.updateProgress( + task: task.copyWith( + progress: progress, + ), + ); + }, + ); + + if (!result.success) { + throw ThumbnailUploadException( + message: 'Failed to upload thumbnail', + error: result.error, + ); + } + + controller.updateProgress( + task: task.copyWith( + status: UploadStatus.complete, + ), + ); + } +} + List? customBundleTags( UploadType type, ) { diff --git a/packages/ardrive_uploader/lib/src/upload_task.dart b/packages/ardrive_uploader/lib/src/upload_task.dart index a451d7a411..dad7782e7f 100644 --- a/packages/ardrive_uploader/lib/src/upload_task.dart +++ b/packages/ardrive_uploader/lib/src/upload_task.dart @@ -37,6 +37,8 @@ class FileUploadTask extends UploadTask { @override UploadType type; + final bool uploadThumbnail; + FileUploadTask({ this.uploadItem, this.isProgressAvailable = true, @@ -51,6 +53,7 @@ class FileUploadTask extends UploadTask { required this.type, this.metadataUploaded = false, this.error, + required this.uploadThumbnail, }) : id = id ?? const Uuid().v4(); @override @@ -71,6 +74,7 @@ class FileUploadTask extends UploadTask { bool? metadataUploaded, Object? error, IOFile? file, + bool? uploadThumbnail, }) { return FileUploadTask( cancelToken: cancelToken ?? this.cancelToken, @@ -86,6 +90,7 @@ class FileUploadTask extends UploadTask { type: type ?? this.type, metadataUploaded: metadataUploaded ?? this.metadataUploaded, error: error ?? this.error, + uploadThumbnail: uploadThumbnail ?? this.uploadThumbnail, ); } } @@ -204,3 +209,75 @@ class FolderUploadTask extends UploadTask { ); } } + +class ThumbnailUploadTask implements UploadTask { + final IOFile file; + final ThumbnailUploadMetadata metadata; + @override + final UploadItem? uploadItem; + @override + final double progress; + @override + final String id; + @override + final bool isProgressAvailable; + @override + final UploadStatus status; + @override + final SecretKey? encryptionKey; + @override + final UploadTaskCancelToken? cancelToken; + @override + final UploadType type; + + ThumbnailUploadTask({ + required this.file, + required this.metadata, + this.uploadItem, + this.progress = 0, + required this.id, + this.isProgressAvailable = true, + this.status = UploadStatus.notStarted, + this.encryptionKey, + this.cancelToken, + required this.type, + }); + + @override + List get content => []; + + @override + Object? error; + + @override + String errorInfo() { + return ''; + } + + @override + ThumbnailUploadTask copyWith({ + UploadItem? uploadItem, + double? progress, + bool? isProgressAvailable, + UploadStatus? status, + String? id, + List? content, + SecretKey? encryptionKey, + UploadTaskCancelToken? cancelToken, + UploadType? type, + Object? error, + }) { + return ThumbnailUploadTask( + file: file, + metadata: metadata, + uploadItem: uploadItem ?? this.uploadItem, + progress: progress ?? this.progress, + id: id ?? this.id, + isProgressAvailable: isProgressAvailable ?? this.isProgressAvailable, + status: status ?? this.status, + encryptionKey: encryptionKey ?? this.encryptionKey, + cancelToken: cancelToken ?? this.cancelToken, + type: type ?? this.type, + ); + } +} diff --git a/packages/ardrive_uploader/test/arfs_upload_metadata_test.dart b/packages/ardrive_uploader/test/arfs_upload_metadata_test.dart index d954b03be0..dd2afc9564 100644 --- a/packages/ardrive_uploader/test/arfs_upload_metadata_test.dart +++ b/packages/ardrive_uploader/test/arfs_upload_metadata_test.dart @@ -263,6 +263,26 @@ void main() { metadata.updateDataTxId(dataTxId); metadata.updateLicenseTxId(licenseTxId); + final json = metadata.toJson(); + expect(json, { + 'name': name, + 'size': size, + 'lastModifiedDate': lastModifiedDate.millisecondsSinceEpoch, + 'dataContentType': dataContentType, + 'dataTxId': dataTxId, + 'licenseTxId': licenseTxId, + }); + }); + // TODO: update test to include the correct thumbnail object + test( + 'toJson returns correct map when dataTxId and licenseTxId and thumbnailTxId are set', + () { + const dataTxId = 'dataTxId'; + const licenseTxId = 'licenseTxId'; + metadata.updateDataTxId(dataTxId); + metadata.updateLicenseTxId(licenseTxId); + // metadata.updateThumbnailTxId('thumbnailTxId'); + final json = metadata.toJson(); expect(json, { 'name': name, diff --git a/packages/ardrive_uploader/test/upload_task_test.dart b/packages/ardrive_uploader/test/upload_task_test.dart index 25977b62b4..a85c81aeff 100644 --- a/packages/ardrive_uploader/test/upload_task_test.dart +++ b/packages/ardrive_uploader/test/upload_task_test.dart @@ -40,6 +40,7 @@ void main() { file: mockFile, metadata: mockMetadata, type: UploadType.turbo, + uploadThumbnail: true, ); expect(task.file, mockFile); @@ -66,6 +67,7 @@ void main() { cancelToken: mockCancelToken, metadataUploaded: true, uploadItem: mockUploadItem, + uploadThumbnail: true, ); expect(task.file, mockFile); @@ -86,6 +88,7 @@ void main() { file: mockFile, metadata: mockMetadata, type: UploadType.turbo, + uploadThumbnail: true, ); final newTask = task.copyWith( @@ -107,6 +110,7 @@ void main() { file: mockFile, metadata: mockMetadata, type: UploadType.turbo, + uploadThumbnail: true, ); final errorInfo = task.errorInfo(); diff --git a/packages/ardrive_utils/lib/ardrive_utils.dart b/packages/ardrive_utils/lib/ardrive_utils.dart index a1e7a4cfca..0e7491c367 100644 --- a/packages/ardrive_utils/lib/ardrive_utils.dart +++ b/packages/ardrive_utils/lib/ardrive_utils.dart @@ -6,6 +6,8 @@ export 'src/app_platform.dart'; export 'src/base2_size.dart'; export 'src/convert_to_usd.dart'; export 'src/entity_tag.dart'; +export 'src/file_type_helper.dart'; +export 'src/generate_thumbnail.dart'; export 'src/get_first_future_result.dart'; export 'src/html/html.dart'; export 'src/license_tag.dart'; diff --git a/packages/ardrive_utils/lib/src/file_type_helper.dart b/packages/ardrive_utils/lib/src/file_type_helper.dart new file mode 100644 index 0000000000..ed42d24301 --- /dev/null +++ b/packages/ardrive_utils/lib/src/file_type_helper.dart @@ -0,0 +1,70 @@ +abstract class FileTypeHelper { + static const List _imageTypes = ['image/']; + static const List _audioTypes = ['audio/']; + static const List _videoTypes = ['video/']; + + static const List _docTypes = [ + 'text/', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/pdf', + ]; + + static const List _codeTypes = [ + 'text/html', + 'text/css', + 'text/javascript', + 'application/javascript', + 'application/json', + 'application/xml', + 'application/xhtml+xml', + 'text/x-c++src', + 'text/x-csrc', + 'text/x-diff', + 'text/x-go', + 'text/x-java', + 'text/x-kotlin', + 'text/x-markdown', + 'text/x-perl', + 'text/x-python', + 'text/x-rustsrc', + 'text/x-swift', + ]; + + static const List _zipTypes = [ + 'application/zip', + 'application/x-rar-compressed' + ]; + + static const List _manifestTypes = [ + 'application/x.arweave-manifest+json', + ]; + + static bool isImage(String contentType) { + return _imageTypes.any((type) => contentType.startsWith(type)); + } + + static bool isAudio(String contentType) { + return _audioTypes.any((type) => contentType.startsWith(type)); + } + + static bool isVideo(String contentType) { + return _videoTypes.any((type) => contentType.startsWith(type)); + } + + static bool isDoc(String contentType) { + return _docTypes.contains(contentType) || contentType.startsWith('text/'); + } + + static bool isCode(String contentType) { + return _codeTypes.any((type) => contentType.startsWith(type)); + } + + static bool isZip(String contentType) { + return _zipTypes.contains(contentType); + } + + static bool isManifest(String contentType) { + return _manifestTypes.contains(contentType); + } +} diff --git a/packages/ardrive_utils/lib/src/generate_thumbnail.dart b/packages/ardrive_utils/lib/src/generate_thumbnail.dart new file mode 100644 index 0000000000..85fad114cd --- /dev/null +++ b/packages/ardrive_utils/lib/src/generate_thumbnail.dart @@ -0,0 +1,49 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_image_compress/flutter_image_compress.dart'; +import 'package:image/image.dart' as img; + +enum ThumbnailSize { + small, + medium, + large, +} + +Future generateThumbnail( + Uint8List data, + ThumbnailSize size, +) async { + var result = await FlutterImageCompress.compressWithList( + data, + minHeight: 100, + minWidth: 100, + quality: 95, + ); + + final thumbnail = img.decodeImage(result)!; + + debugPrint('Thumbnail size: ${thumbnail.length}'); + + return ThumbnailGenerationResult( + thumbnail: img.encodeJpg(thumbnail), + size: thumbnail.length, + height: thumbnail.height, + width: thumbnail.width, + name: size.name, + ); +} + +class ThumbnailGenerationResult { + final Uint8List thumbnail; + final int size; + final int height; + final int width; + final String name; + + ThumbnailGenerationResult({ + required this.thumbnail, + required this.size, + required this.height, + required this.width, + required this.name, + }); +} diff --git a/packages/ardrive_utils/pubspec.yaml b/packages/ardrive_utils/pubspec.yaml index 9f1b348e99..1f0c78e0a1 100644 --- a/packages/ardrive_utils/pubspec.yaml +++ b/packages/ardrive_utils/pubspec.yaml @@ -25,6 +25,8 @@ dependencies: ref: v3.9.0 js: ^0.6.7 equatable: ^2.0.5 + image: ^4.2.0 + flutter_image_compress: ^2.3.0 dev_dependencies: flutter_test: diff --git a/test/utils/file_type_helper_test.dart b/packages/ardrive_utils/test/src/file_type_helper_test.dart similarity index 96% rename from test/utils/file_type_helper_test.dart rename to packages/ardrive_utils/test/src/file_type_helper_test.dart index fb626f2c5d..6da9f30538 100644 --- a/test/utils/file_type_helper_test.dart +++ b/packages/ardrive_utils/test/src/file_type_helper_test.dart @@ -1,5 +1,5 @@ -import 'package:ardrive/utils/file_type_helper.dart'; -import 'package:test/test.dart'; +import 'package:ardrive_utils/src/file_type_helper.dart'; +import 'package:flutter_test/flutter_test.dart'; void main() { group('FileTypeHelper', () { diff --git a/packages/pst/pubspec.lock b/packages/pst/pubspec.lock index 9b3227d25a..8c4d163660 100644 --- a/packages/pst/pubspec.lock +++ b/packages/pst/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + archive: + dependency: transitive + description: + name: archive + sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d + url: "https://pub.dev" + source: hosted + version: "3.6.1" ardrive_http: dependency: "direct main" description: @@ -122,6 +130,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "55d7b444feb71301ef6b8838dbc1ae02e63dd48c8773f3810ff53bb1e2945b32" + url: "https://pub.dev" + source: hosted + version: "0.3.4+1" crypto: dependency: transitive description: @@ -240,6 +256,54 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_image_compress: + dependency: transitive + description: + name: flutter_image_compress + sha256: "45a3071868092a61b11044c70422b04d39d4d9f2ef536f3c5b11fb65a1e7dd90" + url: "https://pub.dev" + source: hosted + version: "2.3.0" + flutter_image_compress_common: + dependency: transitive + description: + name: flutter_image_compress_common + sha256: "7f79bc6c8a363063620b4e372fa86bc691e1cb28e58048cd38e030692fbd99ee" + url: "https://pub.dev" + source: hosted + version: "1.0.5" + flutter_image_compress_macos: + dependency: transitive + description: + name: flutter_image_compress_macos + sha256: "26df6385512e92b3789dc76b613b54b55c457a7f1532e59078b04bf189782d47" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + flutter_image_compress_ohos: + dependency: transitive + description: + name: flutter_image_compress_ohos + sha256: e76b92bbc830ee08f5b05962fc78a532011fcd2041f620b5400a593e96da3f51 + url: "https://pub.dev" + source: hosted + version: "0.0.3" + flutter_image_compress_platform_interface: + dependency: transitive + description: + name: flutter_image_compress_platform_interface + sha256: "579cb3947fd4309103afe6442a01ca01e1e6f93dc53bb4cbd090e8ce34a41889" + url: "https://pub.dev" + source: hosted + version: "1.0.5" + flutter_image_compress_web: + dependency: transitive + description: + name: flutter_image_compress_web + sha256: f02fe352b17f82b72f481de45add240db062a2585850bea1667e82cc4cd6c311 + url: "https://pub.dev" + source: hosted + version: "0.1.4+1" flutter_lints: dependency: "direct dev" description: @@ -314,6 +378,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.2" + image: + dependency: transitive + description: + name: image + sha256: "2237616a36c0d69aef7549ab439b833fb7f9fb9fc861af2cc9ac3eedddd69ca8" + url: "https://pub.dev" + source: hosted + version: "4.2.0" isolated_worker: dependency: transitive description: @@ -442,6 +514,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27 + url: "https://pub.dev" + source: hosted + version: "6.0.2" platform: dependency: transitive description: @@ -583,6 +663,14 @@ packages: url: "https://pub.dev" source: hosted version: "13.0.0" + web: + dependency: transitive + description: + name: web + sha256: "97da13628db363c635202ad97068d47c5b8aa555808e7a9411963c533b449b27" + url: "https://pub.dev" + source: hosted + version: "0.5.1" win32: dependency: transitive description: @@ -599,6 +687,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + xml: + dependency: transitive + description: + name: xml + sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 + url: "https://pub.dev" + source: hosted + version: "6.5.0" sdks: - dart: ">=3.2.0-0 <4.0.0" + dart: ">=3.3.0 <4.0.0" flutter: ">=3.7.12" diff --git a/pubspec.lock b/pubspec.lock index 899c762433..41e25d086a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -850,66 +850,50 @@ packages: dependency: transitive description: name: flutter_image_compress - sha256: "37f1b26399098e5f97b74c1483f534855e7dff68ead6ddaccf747029fb03f29f" + sha256: "45a3071868092a61b11044c70422b04d39d4d9f2ef536f3c5b11fb65a1e7dd90" url: "https://pub.dev" source: hosted - version: "1.1.3" - flutter_inappwebview: - dependency: transitive - description: - name: flutter_inappwebview - sha256: "3e9a443a18ecef966fb930c3a76ca5ab6a7aafc0c7b5e14a4a850cf107b09959" - url: "https://pub.dev" - source: hosted - version: "6.0.0" - flutter_inappwebview_android: - dependency: transitive - description: - name: flutter_inappwebview_android - sha256: d247f6ed417f1f8c364612fa05a2ecba7f775c8d0c044c1d3b9ee33a6515c421 - url: "https://pub.dev" - source: hosted - version: "1.0.13" - flutter_inappwebview_internal_annotations: + version: "2.3.0" + flutter_image_compress_common: dependency: transitive description: - name: flutter_inappwebview_internal_annotations - sha256: "5f80fd30e208ddded7dbbcd0d569e7995f9f63d45ea3f548d8dd4c0b473fb4c8" + name: flutter_image_compress_common + sha256: "7f79bc6c8a363063620b4e372fa86bc691e1cb28e58048cd38e030692fbd99ee" url: "https://pub.dev" source: hosted - version: "1.1.1" - flutter_inappwebview_ios: + version: "1.0.5" + flutter_image_compress_macos: dependency: transitive description: - name: flutter_inappwebview_ios - sha256: f363577208b97b10b319cd0c428555cd8493e88b468019a8c5635a0e4312bd0f + name: flutter_image_compress_macos + sha256: "26df6385512e92b3789dc76b613b54b55c457a7f1532e59078b04bf189782d47" url: "https://pub.dev" source: hosted - version: "1.0.13" - flutter_inappwebview_macos: + version: "1.0.2" + flutter_image_compress_ohos: dependency: transitive description: - name: flutter_inappwebview_macos - sha256: b55b9e506c549ce88e26580351d2c71d54f4825901666bd6cfa4be9415bb2636 + name: flutter_image_compress_ohos + sha256: e76b92bbc830ee08f5b05962fc78a532011fcd2041f620b5400a593e96da3f51 url: "https://pub.dev" source: hosted - version: "1.0.11" - flutter_inappwebview_platform_interface: + version: "0.0.3" + flutter_image_compress_platform_interface: dependency: transitive description: - name: flutter_inappwebview_platform_interface - sha256: "545fd4c25a07d2775f7d5af05a979b2cac4fbf79393b0a7f5d33ba39ba4f6187" + name: flutter_image_compress_platform_interface + sha256: "579cb3947fd4309103afe6442a01ca01e1e6f93dc53bb4cbd090e8ce34a41889" url: "https://pub.dev" source: hosted - version: "1.0.10" - flutter_inappwebview_web: + version: "1.0.5" + flutter_image_compress_web: dependency: transitive description: - name: flutter_inappwebview_web - sha256: d8c680abfb6fec71609a700199635d38a744df0febd5544c5a020bd73de8ee07 + name: flutter_image_compress_web + sha256: f02fe352b17f82b72f481de45add240db062a2585850bea1667e82cc4cd6c311 url: "https://pub.dev" source: hosted - version: "1.0.8" + version: "0.1.4+1" flutter_launcher_icons: dependency: "direct main" description: @@ -1042,10 +1026,10 @@ packages: dependency: transitive description: name: flutter_svg_image - sha256: "32d5970cbcba9a3436f614a34450c14bc91806d561776aec0b5b7ccbe21467cd" + sha256: "8f50ace0234d5d2d349cafe1224379933990f3e4145b9f02f22ba977131396d0" url: "https://pub.dev" source: hosted - version: "1.1.0+1" + version: "1.0.0" flutter_test: dependency: "direct dev" description: flutter @@ -1217,10 +1201,10 @@ packages: dependency: transitive description: name: image - sha256: "4c68bfd5ae83e700b5204c1e74451e7bf3cf750e6843c6e158289cf56bda018e" + sha256: "2237616a36c0d69aef7549ab439b833fb7f9fb9fc861af2cc9ac3eedddd69ca8" url: "https://pub.dev" source: hosted - version: "4.1.7" + version: "4.2.0" image_picker: dependency: transitive description: diff --git a/test/manifest/domain/manifest_repository_test.dart b/test/manifest/domain/manifest_repository_test.dart index 31ffd8404c..98d845bf62 100644 --- a/test/manifest/domain/manifest_repository_test.dart +++ b/test/manifest/domain/manifest_repository_test.dart @@ -169,6 +169,7 @@ void main() async { file: mockManifestFile, metadata: mockMetadata, type: UploadType.turbo, + uploadThumbnail: true, ); uploadTaskAR = FileUploadTask( @@ -176,6 +177,7 @@ void main() async { file: mockManifestFile, metadata: mockMetadata, type: UploadType.d2n, + uploadThumbnail: true, ); when(() => mockUploader.upload( diff --git a/test/models/database/database_test.dart b/test/models/database/database_test.dart deleted file mode 100644 index 89aa251b65..0000000000 --- a/test/models/database/database_test.dart +++ /dev/null @@ -1,43 +0,0 @@ -import 'package:ardrive/models/database/database.dart'; -import 'package:drift_dev/api/migrations.dart'; -import 'package:test/test.dart'; - -import '../../generated_migrations/schema.dart'; - -void main() { - // Initialize SchemaVerifier before all tests - late SchemaVerifier verifier; - - setUpAll(() { - // Initializes SchemaVerifier with GeneratedHelper from drift - verifier = SchemaVerifier(GeneratedHelper()); - }); - - // Utility function to setup database and run migration to a target version - // It returns a Database instance for further validation in tests. - Future migrateDatabase( - SchemaVerifier verifier, int startVersion, int targetVersion) async { - final connection = await verifier.startAt(startVersion); - final db = Database(connection); - await verifier.migrateAndValidate(db, targetVersion); - return db; - } - - group('Database Migration Tests', () { - test('should successfully upgrade database schema from v17 to v19', - () async { - // Executes migration from version 17 to 19 and validates the schema - final db = await migrateDatabase(verifier, 17, 19); - - db.close(); - }); - - test('should successfully upgrade database schema from v18 to v19', - () async { - // Executes migration from version 18 to 19 and validates the schema - final db = await migrateDatabase(verifier, 18, 19); - - db.close(); - }); - }); -} diff --git a/web/index.html b/web/index.html index 314187769c..04207a08f8 100644 --- a/web/index.html +++ b/web/index.html @@ -24,6 +24,9 @@ + + +