diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 1af8855f7a..2f4009c9d8 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -12,7 +12,7 @@ env: PR_NUMBER: ${{ github.event.pull_request.number }} PR_TITLE: ${{ github.event.pull_request.title }} RELEASE_NOTES: ${{ github.event.pull_request.title }} - ${{ github.sha }} - + jobs: pre-build: uses: ./.github/workflows/test.yml @@ -42,7 +42,7 @@ jobs: run: | scr setup flutter config --enable-web - flutter build web --dart-define=environment=development --release --pwa-strategy=none --no-web-resources-cdn + flutter build web --dart-define=environment=development --release --pwa-strategy=none --no-web-resources-cdn --source-maps # JS files cache invalidation - name: main.dart.js and service worker cache invalidation diff --git a/.github/workflows/production.yaml b/.github/workflows/production.yaml index 708a02ec80..55c2e64d6d 100644 --- a/.github/workflows/production.yaml +++ b/.github/workflows/production.yaml @@ -14,6 +14,11 @@ on: env: BUILD_NUMBER: ${{ github.run_number }} RELEASE_NOTES: 'Production' + SENTRY_DSN: ${{secrets.SENTRY_DSN}} + SENTRY_ORG: ${{secrets.SENTRY_ORG}} + SENTRY_AUTH_TOKEN: ${{secrets.SENTRY_AUTH_TOKEN}} + SENTRY_PROJECT: ${{secrets.SENTRY_PROJECT}} + jobs: pre-build: @@ -44,7 +49,8 @@ jobs: run: | scr setup flutter config --enable-web - flutter build web --release --dart-define=environment=production --pwa-strategy=none --no-web-resources-cdn + flutter build web --release --dart-define=environment=production --dart-define=SENTRY_DSN=${SENTRY_DSN} --dart-define=SENTRY_PROJECT=${SENTRY_PROJECT} --dart-define=SENTRY_AUTH_TOKEN=${SENTRY_AUTH_TOKEN} --dart-define=SENTRY_ORG=${SENTRY_ORG} --pwa-strategy=none --no-web-resources-cdn + flutter packages pub run sentry_dart_plugin # Disribute to Firebase - uses: FirebaseExtended/action-hosting-deploy@v0 diff --git a/.github/workflows/staging.yaml b/.github/workflows/staging.yaml index 3a6f67e1f8..cd1624986b 100644 --- a/.github/workflows/staging.yaml +++ b/.github/workflows/staging.yaml @@ -16,6 +16,10 @@ on: env: BUILD_NUMBER: ${{ github.run_number }} RELEASE_NOTES: Staging - ${{ github.sha }} + SENTRY_DSN: ${{secrets.SENTRY_DSN}} + SENTRY_ORG: ${{secrets.SENTRY_ORG}} + SENTRY_AUTH_TOKEN: ${{secrets.SENTRY_AUTH_TOKEN}} + SENTRY_PROJECT: ${{secrets.SENTRY_PROJECT}} jobs: pre-build: @@ -46,7 +50,8 @@ jobs: run: | scr setup flutter config --enable-web - flutter build web --dart-define=environment=staging --release --pwa-strategy=none --no-web-resources-cdn + flutter build web --dart-define=environment=staging --dart-define=SENTRY_DSN=${SENTRY_DSN} --dart-define=SENTRY_PROJECT=${SENTRY_PROJECT} --dart-define=SENTRY_AUTH_TOKEN=${SENTRY_AUTH_TOKEN} --dart-define=SENTRY_ORG=${SENTRY_ORG} --release --pwa-strategy=none --no-web-resources-cdn + flutter packages pub run sentry_dart_plugin # Deploy to github pages - uses: JamesIves/github-pages-deploy-action@4.1.1 diff --git a/android/fastlane/metadata/android/en-US/changelogs/91.txt b/android/fastlane/metadata/android/en-US/changelogs/91.txt new file mode 100644 index 0000000000..326c069fa0 --- /dev/null +++ b/android/fastlane/metadata/android/en-US/changelogs/91.txt @@ -0,0 +1,3 @@ +- Enhances user interface with new options to create manifests for folders with pending files. +- Introduces a reminder pop-up for users with large drives to encourage snapshot usage, enhancing sync efficiency. +- Provides immediate update visibility when renaming a file or folder. \ No newline at end of file diff --git a/lib/app_shell.dart b/lib/app_shell.dart index 9d6626db19..ae85c1b217 100644 --- a/lib/app_shell.dart +++ b/lib/app_shell.dart @@ -1,3 +1,5 @@ +import 'package:ardrive/blocs/prompt_to_snapshot/prompt_to_snapshot_bloc.dart'; +import 'package:ardrive/blocs/prompt_to_snapshot/prompt_to_snapshot_event.dart'; import 'package:ardrive/components/profile_card.dart'; import 'package:ardrive/components/side_bar.dart'; import 'package:ardrive/gift/reedem_button.dart'; @@ -60,9 +62,21 @@ class AppShellState extends State { @override Widget build(BuildContext context) => BlocBuilder( - builder: (context, _) { + builder: (context, drivesState) { Widget buildPage(scaffold) => Material( - child: BlocBuilder( + child: BlocConsumer( + listener: (context, syncState) async { + if (drivesState is DrivesLoadSuccess) { + if (syncState is! SyncInProgress) { + final promptToSnapshotBloc = + context.read(); + + promptToSnapshotBloc.add(SelectedDrive( + driveId: drivesState.selectedDriveId, + )); + } + } + }, builder: (context, syncState) => syncState is SyncInProgress ? Stack( children: [ diff --git a/lib/blocs/create_manifest/create_manifest_cubit.dart b/lib/blocs/create_manifest/create_manifest_cubit.dart index 4bcc944adb..bdd0802eda 100644 --- a/lib/blocs/create_manifest/create_manifest_cubit.dart +++ b/lib/blocs/create_manifest/create_manifest_cubit.dart @@ -31,6 +31,7 @@ class CreateManifestCubit extends Cubit { final TurboUploadService _turboUploadService; final DriveDao _driveDao; final PstService _pst; + bool _hasPendingFiles = false; StreamSubscription? _selectedFolderSubscription; @@ -41,11 +42,13 @@ class CreateManifestCubit extends Cubit { required TurboUploadService turboUploadService, required DriveDao driveDao, required PstService pst, + required bool hasPendingFiles, }) : _profileCubit = profileCubit, _arweave = arweave, _turboUploadService = turboUploadService, _driveDao = driveDao, _pst = pst, + _hasPendingFiles = hasPendingFiles, super(CreateManifestInitial()) { if (drive.isPrivate) { // Extra guardrail to prevent private drives from creating manifests @@ -59,6 +62,8 @@ class CreateManifestCubit extends Cubit { rootFolderNode = await _driveDao.getFolderTree(drive.id, drive.rootFolderId); + _hasPendingFiles = await _hasPendingFilesInFolder(rootFolderNode); + await loadFolder(drive.rootFolderId); } @@ -83,6 +88,40 @@ class CreateManifestCubit extends Cubit { } } + /// recursively check if any files in the folder have pending uploads + Future _hasPendingFilesInFolder(FolderNode folder) async { + final files = folder.getRecursiveFiles(); + final folders = folder.subfolders; + + if (files.isEmpty && folders.isEmpty) { + return false; + } + + final filesWithTx = await _driveDao + .filesInFolderWithRevisionTransactions( + driveId: drive.id, parentFolderId: folder.folder.id) + .get(); + + final hasPendingFiles = filesWithTx.any((e) => + 'pending' == + fileStatusFromTransactions( + e.metadataTx, + e.dataTx, + ).toString()); + + if (hasPendingFiles) { + return true; + } + + for (var folder in folders) { + if (await _hasPendingFilesInFolder(folder)) { + return true; + } + } + + return false; + } + Future loadFolder(String folderId) async { await _selectedFolderSubscription?.cancel(); @@ -174,7 +213,6 @@ class CreateManifestCubit extends Cubit { (state as CreateManifestPreparingManifest).parentFolder; final folderNode = rootFolderNode.searchForFolder(parentFolder.id) ?? await _driveDao.getFolderTree(drive.id, parentFolder.id); - final arweaveManifest = ManifestData.fromFolderNode( folderNode: folderNode, ); @@ -211,7 +249,9 @@ class CreateManifestCubit extends Cubit { addManifestToDatabase() => _driveDao.transaction( () async { await _driveDao.writeFileEntity( - manifestFileEntity, '${parentFolder.path}/$manifestName'); + manifestFileEntity, + '${parentFolder.path}/$manifestName', + ); await _driveDao.insertFileRevision( manifestFileEntity.toRevisionCompanion( performedAction: existingManifestFileId == null @@ -221,6 +261,9 @@ class CreateManifestCubit extends Cubit { ); }, ); + + logger.d('Manifest has pending files: $_hasPendingFiles'); + final canUseTurbo = _turboUploadService.useTurboUpload && arweaveManifest.size < _turboUploadService.allowedDataItemSize; if (canUseTurbo) { @@ -228,6 +271,7 @@ class CreateManifestCubit extends Cubit { CreateManifestTurboUploadConfirmation( manifestSize: arweaveManifest.size, manifestName: manifestName, + folderHasPendingFiles: _hasPendingFiles, manifestDataItems: [manifestDataItem, manifestMetaDataItem], addManifestToDatabase: addManifestToDatabase, ), @@ -275,6 +319,7 @@ class CreateManifestCubit extends Cubit { CreateManifestUploadConfirmation( manifestSize: arweaveManifest.size, manifestName: manifestName, + folderHasPendingFiles: _hasPendingFiles, arUploadCost: arUploadCost, usdUploadCost: usdUploadCost, uploadManifestParams: uploadManifestParams, diff --git a/lib/blocs/create_manifest/create_manifest_state.dart b/lib/blocs/create_manifest/create_manifest_state.dart index a11d7b56f6..d80bb1a6eb 100644 --- a/lib/blocs/create_manifest/create_manifest_state.dart +++ b/lib/blocs/create_manifest/create_manifest_state.dart @@ -91,7 +91,7 @@ class CreateManifestInsufficientBalance extends CreateManifestState { class CreateManifestUploadConfirmation extends CreateManifestState { final int manifestSize; final String manifestName; - + final bool folderHasPendingFiles; final String arUploadCost; final double? usdUploadCost; @@ -100,6 +100,7 @@ class CreateManifestUploadConfirmation extends CreateManifestState { CreateManifestUploadConfirmation({ required this.manifestSize, required this.manifestName, + required this.folderHasPendingFiles, required this.arUploadCost, required this.usdUploadCost, required this.uploadManifestParams, @@ -109,6 +110,7 @@ class CreateManifestUploadConfirmation extends CreateManifestState { List get props => [ manifestSize, manifestName, + folderHasPendingFiles, arUploadCost, usdUploadCost, uploadManifestParams, @@ -119,12 +121,14 @@ class CreateManifestUploadConfirmation extends CreateManifestState { class CreateManifestTurboUploadConfirmation extends CreateManifestState { final int manifestSize; final String manifestName; + final bool folderHasPendingFiles; final List manifestDataItems; final Future Function() addManifestToDatabase; CreateManifestTurboUploadConfirmation({ required this.manifestSize, required this.manifestName, + required this.folderHasPendingFiles, required this.manifestDataItems, required this.addManifestToDatabase, }); @@ -133,6 +137,7 @@ class CreateManifestTurboUploadConfirmation extends CreateManifestState { List get props => [ manifestSize, manifestName, + folderHasPendingFiles, manifestDataItems, addManifestToDatabase, ]; diff --git a/lib/blocs/drive_detail/drive_detail_cubit.dart b/lib/blocs/drive_detail/drive_detail_cubit.dart index 4531f25d7e..1596a66c6f 100644 --- a/lib/blocs/drive_detail/drive_detail_cubit.dart +++ b/lib/blocs/drive_detail/drive_detail_cubit.dart @@ -394,11 +394,13 @@ class DriveDetailCubit extends Cubit { ); } - void refreshDriveDataTable() { + void refreshDriveDataTable() async { _refreshSelectedItem = true; if (state is DriveDetailLoadSuccess) { - emit((state as DriveDetailLoadSuccess).copyWith()); + await Future.delayed(const Duration(milliseconds: 100)); + emit((state as DriveDetailLoadSuccess) + .copyWith(forceRebuildKey: UniqueKey())); } } diff --git a/lib/blocs/drive_detail/drive_detail_state.dart b/lib/blocs/drive_detail/drive_detail_state.dart index e1c0b0d5aa..f06dee299c 100644 --- a/lib/blocs/drive_detail/drive_detail_state.dart +++ b/lib/blocs/drive_detail/drive_detail_state.dart @@ -35,6 +35,8 @@ class DriveDetailLoadSuccess extends DriveDetailState { final List currentFolderContents; + final Key? forceRebuildKey; + DriveDetailLoadSuccess({ required this.currentDrive, required this.hasWritePermissions, @@ -51,6 +53,7 @@ class DriveDetailLoadSuccess extends DriveDetailState { required this.driveIsEmpty, this.selectedItem, required this.currentFolderContents, + this.forceRebuildKey, }); DriveDetailLoadSuccess copyWith({ @@ -69,8 +72,10 @@ class DriveDetailLoadSuccess extends DriveDetailState { bool? hasFoldersSelected, ArDriveDataTableItem? selectedItem, List? currentFolderContents, + Key? forceRebuildKey, }) => DriveDetailLoadSuccess( + forceRebuildKey: forceRebuildKey ?? this.forceRebuildKey, selectedItem: selectedItem ?? this.selectedItem, hasFoldersSelected: hasFoldersSelected ?? this.hasFoldersSelected, currentDrive: currentDrive ?? this.currentDrive, @@ -105,6 +110,7 @@ class DriveDetailLoadSuccess extends DriveDetailState { _equatableBust, driveIsEmpty, multiselect, + forceRebuildKey, ]; SelectedItem? maybeSelectedItem() => selectedItems.isNotEmpty ? selectedItems.first : null; diff --git a/lib/blocs/drives/drives_cubit.dart b/lib/blocs/drives/drives_cubit.dart index f5db15128f..f8fd27e19d 100644 --- a/lib/blocs/drives/drives_cubit.dart +++ b/lib/blocs/drives/drives_cubit.dart @@ -2,6 +2,8 @@ import 'dart:async'; import 'package:ardrive/authentication/ardrive_auth.dart'; import 'package:ardrive/blocs/blocs.dart'; +import 'package:ardrive/blocs/prompt_to_snapshot/prompt_to_snapshot_bloc.dart'; +import 'package:ardrive/blocs/prompt_to_snapshot/prompt_to_snapshot_event.dart'; import 'package:ardrive/core/activity_tracker.dart'; import 'package:ardrive/models/models.dart'; import 'package:ardrive/utils/user_utils.dart'; @@ -17,6 +19,7 @@ part 'drives_state.dart'; /// It works even if the user profile is unavailable. class DrivesCubit extends Cubit { final ProfileCubit _profileCubit; + final PromptToSnapshotBloc _promptToSnapshotBloc; final DriveDao _driveDao; final ArDriveAuth _auth; @@ -26,9 +29,11 @@ class DrivesCubit extends Cubit { required ArDriveAuth auth, this.initialSelectedDriveId, required ProfileCubit profileCubit, + required PromptToSnapshotBloc promptToSnapshotBloc, required DriveDao driveDao, required ActivityTracker activityTracker, }) : _profileCubit = profileCubit, + _promptToSnapshotBloc = promptToSnapshotBloc, _driveDao = driveDao, _auth = auth, super(DrivesLoadInProgress()) { @@ -52,7 +57,7 @@ class DrivesCubit extends Cubit { ).listen((drives) async { final state = this.state; - final profile = _profileCubit.state; + final profileState = _profileCubit.state; String? selectedDriveId; @@ -64,22 +69,26 @@ class DrivesCubit extends Cubit { } final walletAddress = - profile is ProfileLoggedIn ? profile.walletAddress : null; + profileState is ProfileLoggedIn ? profileState.walletAddress : null; final ghostFolders = await _driveDao.ghostFolders().get(); final sharedDrives = drives.where((d) => !isDriveOwner(auth, d.ownerAddress)).toList(); + final userDrives = drives + .where((d) => profileState is ProfileLoggedIn + ? d.ownerAddress == walletAddress + : false) + .toList(); + + _promptToSnapshotBloc.add(SelectedDrive(driveId: selectedDriveId)); + emit( DrivesLoadSuccess( selectedDriveId: selectedDriveId, // If the user is not logged in, all drives are considered shared ones. - userDrives: drives - .where((d) => profile is ProfileLoggedIn - ? d.ownerAddress == walletAddress - : false) - .toList(), + userDrives: userDrives, sharedDrives: sharedDrives, drivesWithAlerts: ghostFolders.map((e) => e.driveId).toList(), canCreateNewDrive: _profileCubit.state is ProfileLoggedIn, @@ -89,16 +98,28 @@ class DrivesCubit extends Cubit { } void selectDrive(String driveId) { - final canCreateNewDrive = _profileCubit.state is ProfileLoggedIn; - final state = this.state is DrivesLoadSuccess - ? (this.state as DrivesLoadSuccess).copyWith(selectedDriveId: driveId) - : DrivesLoadedWithNoDrivesFound(canCreateNewDrive: canCreateNewDrive); + final profileIsLoggedIn = _profileCubit.state is ProfileLoggedIn; + final canCreateNewDrive = profileIsLoggedIn; + final DrivesState state; + if (this.state is DrivesLoadSuccess) { + state = (this.state as DrivesLoadSuccess).copyWith( + selectedDriveId: driveId, + ); + _promptToSnapshotBloc.add(SelectedDrive(driveId: driveId)); + } else { + state = DrivesLoadedWithNoDrivesFound( + canCreateNewDrive: canCreateNewDrive, + ); + _promptToSnapshotBloc.add(const SelectedDrive(driveId: null)); + } emit(state); } void cleanDrives() { initialSelectedDriveId = null; + _promptToSnapshotBloc.add(const SelectedDrive(driveId: null)); + final state = DrivesLoadSuccess( selectedDriveId: null, userDrives: const [], @@ -110,21 +131,27 @@ class DrivesCubit extends Cubit { } void _resetDriveSelection(DriveID detachedDriveId) { - final canCreateNewDrive = _profileCubit.state is ProfileLoggedIn; + final profileIsLoggedIn = _profileCubit.state is ProfileLoggedIn; + final canCreateNewDrive = profileIsLoggedIn; if (state is DrivesLoadSuccess) { final state = this.state as DrivesLoadSuccess; state.userDrives.removeWhere((drive) => drive.id == detachedDriveId); state.sharedDrives.removeWhere((drive) => drive.id == detachedDriveId); - final firstOrNullDrive = state.userDrives.isNotEmpty + final firstOrNullDriveId = state.userDrives.isNotEmpty ? state.userDrives.first.id : state.sharedDrives.isNotEmpty ? state.sharedDrives.first.id : null; - if (firstOrNullDrive != null) { - emit(state.copyWith(selectedDriveId: firstOrNullDrive)); + _promptToSnapshotBloc.add(SelectedDrive( + driveId: firstOrNullDriveId, + )); + if (firstOrNullDriveId != null) { + emit(state.copyWith(selectedDriveId: firstOrNullDriveId)); return; } } + + _promptToSnapshotBloc.add(const SelectedDrive(driveId: null)); emit(DrivesLoadedWithNoDrivesFound(canCreateNewDrive: canCreateNewDrive)); } diff --git a/lib/blocs/feedback_survey/feedback_survey_cubit.dart b/lib/blocs/feedback_survey/feedback_survey_cubit.dart index 98b281a0e2..9f703a796e 100644 --- a/lib/blocs/feedback_survey/feedback_survey_cubit.dart +++ b/lib/blocs/feedback_survey/feedback_survey_cubit.dart @@ -12,7 +12,6 @@ class FeedbackSurveyCubit extends Cubit { FeedbackSurveyCubit( FeedbackSurveyState initialState, { - /// takes a KeyValueStore for testing purposes KeyValueStore? store, }) : super(initialState) { diff --git a/lib/blocs/prompt_to_snapshot/prompt_to_snapshot_bloc.dart b/lib/blocs/prompt_to_snapshot/prompt_to_snapshot_bloc.dart new file mode 100644 index 0000000000..8a619366fb --- /dev/null +++ b/lib/blocs/prompt_to_snapshot/prompt_to_snapshot_bloc.dart @@ -0,0 +1,285 @@ +import 'package:ardrive/blocs/profile/profile_cubit.dart'; +import 'package:ardrive/blocs/prompt_to_snapshot/prompt_to_snapshot_event.dart'; +import 'package:ardrive/blocs/prompt_to_snapshot/prompt_to_snapshot_state.dart'; +import 'package:ardrive/models/daos/daos.dart'; +import 'package:ardrive/user/repositories/user_repository.dart'; +import 'package:ardrive/utils/debouncer.dart'; +import 'package:ardrive/utils/key_value_store.dart'; +import 'package:ardrive/utils/local_key_value_store.dart'; +import 'package:ardrive/utils/logger.dart'; +import 'package:ardrive_utils/ardrive_utils.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +const Duration defaultDurationBeforePrompting = Duration(seconds: 20); +const int defaultNumberOfTxsBeforeSnapshot = 1000; + +class PromptToSnapshotBloc + extends Bloc { + final UserRepository userRepository; + final ProfileCubit profileCubit; + final DriveDao driveDao; + late Duration _durationBeforePrompting; + late Debouncer _debouncer; + late int _numberOfTxsBeforeSnapshot; + + bool _isSyncRunning = false; + + static KeyValueStore? _maybeStore; + + Future get owner async { + final currentOwner = await userRepository.getOwnerOfDefaultProfile(); + return currentOwner; + } + + Future get storeKey async { + final owner = await this.owner; + + if (owner == null) { + throw Exception('Cannot get store key because owner is null'); + } + + return 'dont-ask-to-snapshot-again_$owner'; + } + + Duration get durationBeforePrompting => _durationBeforePrompting; + + PromptToSnapshotBloc({ + required this.userRepository, + required this.profileCubit, + required this.driveDao, + KeyValueStore? store, + Duration durationBeforePrompting = defaultDurationBeforePrompting, + int numberOfTxsBeforeSnapshot = defaultNumberOfTxsBeforeSnapshot, + }) : super(const PromptToSnapshotIdle()) { + on(_onCountSyncedTxs); + on(_onDriveSelected); + on(_onDriveSnapshotting); + on(_onSyncRunning); + on(_onDriveSnapshotted); + on(_onDismissDontAskAgain); + on( + (event, emit) => emit(const PromptToSnapshotIdle())); + + _maybeStore ??= store; + _durationBeforePrompting = durationBeforePrompting; + _debouncer = Debouncer(delay: durationBeforePrompting); + _numberOfTxsBeforeSnapshot = numberOfTxsBeforeSnapshot; + } + + Future get _store async { + /// lazily initialize KeyValueStore + _maybeStore ??= await LocalKeyValueStore.getInstance(); + return _maybeStore!; + } + + Future _onCountSyncedTxs( + CountSyncedTxs event, + Emitter emit, + ) async { + logger.d( + '[PROMPT TO SNAPSHOT] Counting ${event.txsSyncedWithGqlCount} TXs for drive ${event.driveId}', + ); + + if (event.wasDeepSync) { + logger.d('[PROMPT TO SNAPSHOT] The count came from a deep sync'); + CountOfTxsSyncedWithGql.resetForDrive(event.driveId); + } + + CountOfTxsSyncedWithGql.countForDrive( + event.driveId, + event.txsSyncedWithGqlCount, + ); + } + + Future _onDriveSelected( + SelectedDrive event, + Emitter emit, + ) async { + if (_isSyncRunning) { + logger.d( + '[PROMPT TO SNAPSHOT] The sync is running, so we won\'t prompt to snapshot', + ); + _debouncer.cancel(); + return; + } + + final owner = await this.owner; + + if (owner == null) { + logger.d( + '[PROMPT TO SNAPSHOT] The owner is null, so we won\'t prompt to snapshot', + ); + _debouncer.cancel(); + return; + } + + if (event.driveId == null) { + if (state is PromptToSnapshotIdle) { + logger.d( + '[PROMPT TO SNAPSHOT] The drive id is null and the state is idle'); + emit(const PromptToSnapshotIdle()); + } + _debouncer.cancel(); + return; + } + + final hasWritePermissions = await _hasWritePermission(event.driveId); + if (!hasWritePermissions) { + logger.d( + '[PROMPT TO SNAPSHOT] The user doesn\'t have write permissions,' + ' so we won\'t prompt to snapshot', + ); + _debouncer.cancel(); + return; + } + + logger.d('[PROMPT TO SNAPSHOT] Selected drive ${event.driveId}'); + + final shouldAskAgain = await _shouldAskToSnapshotAgain(); + + logger.d( + '[PROMPT TO SNAPSHOT] Will attempt to prompt for drive ${event.driveId}' + ' in ${_durationBeforePrompting.inSeconds}s', + ); + + await _debouncer.run(() async { + final stateIsIdle = state is PromptToSnapshotIdle; + final wouldDriveBenefitFromSnapshot = event.driveId != null && + CountOfTxsSyncedWithGql.wouldDriveBenefitFromSnapshot( + event.driveId!, + _numberOfTxsBeforeSnapshot, + ); + + if (!_isSyncRunning && + shouldAskAgain && + wouldDriveBenefitFromSnapshot && + hasWritePermissions && + !isClosed && + stateIsIdle) { + logger.d( + '[PROMPT TO SNAPSHOT] Prompting to snapshot for ${event.driveId}'); + emit(PromptToSnapshotPrompting(driveId: event.driveId!)); + } else { + logger.d( + '[PROMPT TO SNAPSHOT] Didn\'t prompt for ${event.driveId}.' + ' isSyncRunning: $_isSyncRunning' + ' shoudAskAgain: $shouldAskAgain' + ' wouldDriveBenefitFromSnapshot: $wouldDriveBenefitFromSnapshot' + ' hasWritePermissions: $hasWritePermissions' + ' isBlocClosed: $isClosed' + ' stateIsIdle: $stateIsIdle - ${state.runtimeType}', + ); + } + }).catchError((e) { + logger.d('[PROMPT TO SNAPSHOT] Debuncer cancelled for ${event.driveId}'); + }); + } + + Future _hasWritePermission(DriveID? driveId) async { + final selectedDrive = driveId == null + ? null + : await driveDao.driveById(driveId: driveId).getSingleOrNull(); + final profileState = profileCubit.state; + final hasWritePermissions = profileState is ProfileLoggedIn && + selectedDrive?.ownerAddress == profileState.walletAddress; + + return hasWritePermissions; + } + + void _onDriveSnapshotting( + DriveSnapshotting event, + Emitter emit, + ) { + logger.d('[PROMPT TO SNAPSHOT] Drive ${event.driveId} is snapshotting'); + + emit(PromptToSnapshotSnapshotting(driveId: event.driveId)); + } + + void _onSyncRunning( + SyncRunning event, + Emitter emit, + ) { + logger.d('[PROMPT TO SNAPSHOT] Sync status changed: ${event.isRunning}'); + + _isSyncRunning = event.isRunning; + } + + Future _onDriveSnapshotted( + DriveSnapshotted event, + Emitter emit, + ) async { + logger.d( + '[PROMPT TO SNAPSHOT] Drive ${event.driveId} was snapshotted' + ' with ${event.txsSyncedWithGqlCount} TXs', + ); + + CountOfTxsSyncedWithGql.resetForDrive(event.driveId); + CountOfTxsSyncedWithGql.countForDrive( + event.driveId, + event.txsSyncedWithGqlCount, + ); + emit(const PromptToSnapshotIdle()); + } + + Future _onDismissDontAskAgain( + DismissDontAskAgain event, + Emitter emit, + ) async { + logger.d( + '[PROMPT TO SNAPSHOT] Asked not to prompt again: ${event.dontAskAgain}'); + + await _dontAskToSnapshotAgain(event.dontAskAgain); + emit(const PromptToSnapshotIdle()); + } + + Future _dontAskToSnapshotAgain( + bool dontAskAgain, + ) async { + await (await _store).putBool(await storeKey, dontAskAgain); + } + + Future _shouldAskToSnapshotAgain() async { + final store = await _store; + final value = await store.getBool(await storeKey); + return value != true; + } + + @override + Future close() async { + _debouncer.cancel(); + return super.close(); + } +} + +abstract class CountOfTxsSyncedWithGql { + static final Map _countOfTxsSynceWithGqlOfDrive = {}; + + static int _getForDrive(DriveID driveId) { + return _countOfTxsSynceWithGqlOfDrive[driveId] ?? 0; + } + + static void countForDrive(DriveID driveId, int count) { + final currentCount = _getForDrive(driveId); + _countOfTxsSynceWithGqlOfDrive[driveId] = currentCount + count; + } + + static void resetForDrive(DriveID driveId) { + _countOfTxsSynceWithGqlOfDrive.remove(driveId); + } + + static bool wouldDriveBenefitFromSnapshot( + DriveID driveId, + int numberOfTxsBeforeSnapshot, + ) { + final count = _getForDrive(driveId); + final wouldBenefit = count >= numberOfTxsBeforeSnapshot; + + logger.d( + '[PROMPT TO SNAPSHOT] Would drive $driveId' + ' ($count / $numberOfTxsBeforeSnapshot TXs) benefit from a snapshot:' + ' $wouldBenefit', + ); + + return wouldBenefit; + } +} diff --git a/lib/blocs/prompt_to_snapshot/prompt_to_snapshot_event.dart b/lib/blocs/prompt_to_snapshot/prompt_to_snapshot_event.dart new file mode 100644 index 0000000000..87eb272277 --- /dev/null +++ b/lib/blocs/prompt_to_snapshot/prompt_to_snapshot_event.dart @@ -0,0 +1,76 @@ +import 'package:ardrive_utils/ardrive_utils.dart'; +import 'package:equatable/equatable.dart'; + +abstract class PromptToSnapshotEvent extends Equatable { + final DriveID? driveId; + const PromptToSnapshotEvent({required this.driveId}); + + @override + List get props => []; +} + +class CountSyncedTxs extends PromptToSnapshotEvent { + final int txsSyncedWithGqlCount; + final bool wasDeepSync; + + @override + String get driveId => super.driveId!; + + const CountSyncedTxs({ + required DriveID driveId, + required this.txsSyncedWithGqlCount, + required this.wasDeepSync, + }) : super(driveId: driveId); + + @override + List get props => [driveId, txsSyncedWithGqlCount, wasDeepSync]; +} + +class SelectedDrive extends PromptToSnapshotEvent { + const SelectedDrive({required super.driveId}); +} + +class SyncRunning extends PromptToSnapshotEvent { + final bool isRunning; + + const SyncRunning({ + required this.isRunning, + }) : super(driveId: null); + + @override + List get props => [driveId ?? '', isRunning]; +} + +class DriveSnapshotting extends PromptToSnapshotEvent { + @override + String get driveId => super.driveId!; + + const DriveSnapshotting({required DriveID driveId}) : super(driveId: driveId); +} + +class DriveSnapshotted extends PromptToSnapshotEvent { + final int txsSyncedWithGqlCount; + + @override + String get driveId => super.driveId!; + + const DriveSnapshotted({ + required DriveID driveId, + this.txsSyncedWithGqlCount = 0, + }) : super(driveId: driveId); + + @override + List get props => [driveId, txsSyncedWithGqlCount]; +} + +class DismissDontAskAgain extends PromptToSnapshotEvent { + final bool dontAskAgain; + + const DismissDontAskAgain({ + required this.dontAskAgain, + }) : super(driveId: null); +} + +class ClosePromptToSnapshot extends PromptToSnapshotEvent { + const ClosePromptToSnapshot() : super(driveId: null); +} diff --git a/lib/blocs/prompt_to_snapshot/prompt_to_snapshot_state.dart b/lib/blocs/prompt_to_snapshot/prompt_to_snapshot_state.dart new file mode 100644 index 0000000000..1e8c0e4851 --- /dev/null +++ b/lib/blocs/prompt_to_snapshot/prompt_to_snapshot_state.dart @@ -0,0 +1,51 @@ +import 'package:ardrive_utils/ardrive_utils.dart'; +import 'package:equatable/equatable.dart'; + +abstract class PromptToSnapshotState extends Equatable { + final DriveID? driveId; + + const PromptToSnapshotState({ + required this.driveId, + }); + + @override + List get props => [driveId ?? '']; +} + +class PromptToSnapshotIdle extends PromptToSnapshotState { + const PromptToSnapshotIdle() : super(driveId: null); +} + +class PromptToSnapshotPrompting extends PromptToSnapshotState { + @override + String get driveId => super.driveId!; + + const PromptToSnapshotPrompting({ + required DriveID driveId, + }) : super(driveId: driveId); + + PromptToSnapshotPrompting copyWith({ + String? driveId, + }) { + return PromptToSnapshotPrompting( + driveId: driveId ?? this.driveId, + ); + } +} + +class PromptToSnapshotSnapshotting extends PromptToSnapshotState { + @override + String get driveId => super.driveId!; + + const PromptToSnapshotSnapshotting({ + required DriveID driveId, + }) : super(driveId: driveId); + + PromptToSnapshotSnapshotting copyWith({ + String? driveId, + }) { + return PromptToSnapshotSnapshotting( + driveId: driveId ?? this.driveId, + ); + } +} diff --git a/lib/blocs/sync/sync_cubit.dart b/lib/blocs/sync/sync_cubit.dart index 47846db7dd..4ffa1a5022 100644 --- a/lib/blocs/sync/sync_cubit.dart +++ b/lib/blocs/sync/sync_cubit.dart @@ -4,6 +4,8 @@ import 'dart:math'; import 'package:ardrive/blocs/activity/activity_cubit.dart'; import 'package:ardrive/blocs/blocs.dart'; import 'package:ardrive/blocs/constants.dart'; +import 'package:ardrive/blocs/prompt_to_snapshot/prompt_to_snapshot_bloc.dart'; +import 'package:ardrive/blocs/prompt_to_snapshot/prompt_to_snapshot_event.dart'; import 'package:ardrive/blocs/sync/ghost_folder.dart'; import 'package:ardrive/core/activity_tracker.dart'; import 'package:ardrive/entities/entities.dart'; @@ -52,6 +54,7 @@ const _pendingWaitTime = Duration(days: 1); class SyncCubit extends Cubit { final ProfileCubit _profileCubit; final ActivityCubit _activityCubit; + final PromptToSnapshotBloc _promptToSnapshotBloc; final ArweaveService _arweave; final DriveDao _driveDao; final Database _db; @@ -71,6 +74,7 @@ class SyncCubit extends Cubit { SyncCubit({ required ProfileCubit profileCubit, required ActivityCubit activityCubit, + required PromptToSnapshotBloc promptToSnapshotBloc, required ArweaveService arweave, required DriveDao driveDao, required Database db, @@ -79,6 +83,7 @@ class SyncCubit extends Cubit { required ActivityTracker activityTracker, }) : _profileCubit = profileCubit, _activityCubit = activityCubit, + _promptToSnapshotBloc = promptToSnapshotBloc, _arweave = arweave, _driveDao = driveDao, _db = db, @@ -116,8 +121,9 @@ class SyncCubit extends Cubit { void _restartSync() { logger.d( - 'Attempting to create a sync subscription when the window regains focus.' - ' Is Cubit active? ${!isClosed}'); + 'Attempting to create a sync subscription when the window regains focus.' + ' Is Cubit active? ${!isClosed}', + ); if (_lastSync != null) { final syncInterval = _configService.config.autoSyncIntervalInSeconds; @@ -127,8 +133,11 @@ class SyncCubit extends Cubit { if (!isTimerDurationReadyToSync) { logger.d( - 'Cannot restart sync when the window is focused. Is it currently active? ${!isClosed}.' - ' Last sync occurred $minutesSinceLastSync seconds ago, but it should be at least $syncInterval seconds.'); + 'Cannot restart sync when the window is focused. Is it currently' + ' active? ${!isClosed}.' + ' Last sync occurred $minutesSinceLastSync seconds ago, but it' + ' should be at least $syncInterval seconds.', + ); return; } @@ -256,6 +265,8 @@ class SyncCubit extends Cubit { ), ); + _promptToSnapshotBloc.add(const SyncRunning(isRunning: true)); + _syncProgress = _syncProgress.copyWith(drivesCount: drives.length); logger.d('Current block height number $currentBlockHeight'); final driveSyncProcesses = drives.map( @@ -277,6 +288,7 @@ class SyncCubit extends Cubit { (_syncProgress.drivesCount - _syncProgress.drivesSynced), ownerAddress: drive.ownerAddress, configService: _configService, + promptToSnapshotBloc: _promptToSnapshotBloc, ); } catch (error, stackTrace) { logger.e( @@ -357,8 +369,14 @@ class SyncCubit extends Cubit { _lastSync = DateTime.now(); logger.i( - 'Syncing drives finished. Drives quantity: ${_syncProgress.drivesCount}. The total progress was ${(_syncProgress.progress * 100).roundToDouble()}%. The sync process took: ${_lastSync!.difference(_initSync).inMilliseconds}ms to finish'); + 'Syncing drives finished. Drives quantity: ${_syncProgress.drivesCount}.' + ' The total progress was' + ' ${(_syncProgress.progress * 100).roundToDouble()}%.' + ' The sync process took:' + ' ${_lastSync!.difference(_initSync).inMilliseconds}ms to finish', + ); + _promptToSnapshotBloc.add(const SyncRunning(isRunning: false)); emit(SyncIdle()); } diff --git a/lib/blocs/sync/utils/sync_drive.dart b/lib/blocs/sync/utils/sync_drive.dart index f133c19a1d..1735af1551 100644 --- a/lib/blocs/sync/utils/sync_drive.dart +++ b/lib/blocs/sync/utils/sync_drive.dart @@ -18,6 +18,7 @@ Stream _syncDrive( required Map ghostFolders, required String ownerAddress, required ConfigService configService, + required PromptToSnapshotBloc promptToSnapshotBloc, }) async* { /// Variables to count the current drive's progress information final drive = await driveDao.driveById(driveId: driveId).getSingle(); @@ -162,8 +163,17 @@ Stream _syncDrive( yield percentage; } } + logger.d('Done fetching data - ${gqlDriveHistory.driveId}'); + promptToSnapshotBloc.add( + CountSyncedTxs( + driveId: driveId, + txsSyncedWithGqlCount: gqlDriveHistory.txCount, + wasDeepSync: lastBlockHeight == 0, + ), + ); + final fetchPhaseTotalTime = DateTime.now().difference(fetchPhaseStartDT).inMilliseconds; diff --git a/lib/blocs/upload/upload_cubit.dart b/lib/blocs/upload/upload_cubit.dart index 6113d0e3ad..bac026af20 100644 --- a/lib/blocs/upload/upload_cubit.dart +++ b/lib/blocs/upload/upload_cubit.dart @@ -268,9 +268,7 @@ class UploadCubit extends Cubit { .filesInFolderWithName( driveId: driveId, name: folder.name, - parentFolderId: folders[folder.parentFolderPath] != null - ? folders[folder.parentFolderPath]!.id - : _targetFolder.id, + parentFolderId: folder.parentFolderId, ) .map((f) => f.id) .getSingleOrNull(); @@ -800,7 +798,6 @@ class UploadCubit extends Cubit { entity.txId = fileMetadata.metadataTxId!; _driveDao.transaction(() async { - // If path is a blob from drag and drop, use file name. Else use the path field from folder upload final filePath = '${_targetFolder.path}/${metadata.name}'; await _driveDao.writeFileEntity(entity, filePath); await _driveDao.insertFileRevision( diff --git a/lib/components/create_manifest_form.dart b/lib/components/create_manifest_form.dart index a3ac607e19..a3a3a1d73d 100644 --- a/lib/components/create_manifest_form.dart +++ b/lib/components/create_manifest_form.dart @@ -25,6 +25,7 @@ import 'components.dart'; Future promptToCreateManifest( BuildContext context, { required Drive drive, + required bool hasPendingFiles, }) { return showArDriveDialog( context, @@ -32,6 +33,7 @@ Future promptToCreateManifest( create: (context) => CreateManifestCubit( drive: drive, profileCubit: context.read(), + hasPendingFiles: hasPendingFiles, arweave: context.read(), turboUploadService: context.read(), driveDao: context.read(), @@ -256,15 +258,27 @@ class _CreateManifestFormState extends State { )); } if (state is CreateManifestTurboUploadConfirmation) { + final hasPendingFiles = state.folderHasPendingFiles; + Navigator.pop(context); return ArDriveStandardModal( width: kMediumDialogWidth, - title: appLocalizationsOf(context).createManifestEmphasized, + title: hasPendingFiles + ? appLocalizationsOf(context).filesPending + : appLocalizationsOf(context).createManifestEmphasized, content: SizedBox( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (hasPendingFiles) ...[ + Text( + appLocalizationsOf(context) + .filesPendingManifestExplanation, + style: textStyle, + ), + const Divider(), + ], ConstrainedBox( constraints: const BoxConstraints(maxHeight: 256), child: Scrollbar( diff --git a/lib/components/create_snapshot_dialog.dart b/lib/components/create_snapshot_dialog.dart index a3e90bf1ea..4bc385ab0f 100644 --- a/lib/components/create_snapshot_dialog.dart +++ b/lib/components/create_snapshot_dialog.dart @@ -1,6 +1,8 @@ import 'package:ardrive/authentication/ardrive_auth.dart'; import 'package:ardrive/blocs/blocs.dart'; import 'package:ardrive/blocs/create_snapshot/create_snapshot_cubit.dart'; +import 'package:ardrive/blocs/prompt_to_snapshot/prompt_to_snapshot_bloc.dart'; +import 'package:ardrive/blocs/prompt_to_snapshot/prompt_to_snapshot_event.dart'; import 'package:ardrive/components/components.dart'; import 'package:ardrive/components/payment_method_selector_widget.dart'; import 'package:ardrive/models/models.dart'; @@ -24,6 +26,9 @@ Future promptToCreateSnapshot( BuildContext context, Drive drive, ) async { + final PromptToSnapshotBloc promptToSnapshotBloc = + context.read(); + promptToSnapshotBloc.add(DriveSnapshotting(driveId: drive.id)); return showAnimatedDialog( context, barrierDismissible: false, @@ -44,21 +49,40 @@ Future promptToCreateSnapshot( ), child: CreateSnapshotDialog( drive: drive, + promptToSnapshotBloc: promptToSnapshotBloc, ), ), - ); + ).then((_) { + promptToSnapshotBloc.add(const SelectedDrive(driveId: null)); + }); } class CreateSnapshotDialog extends StatelessWidget { final Drive drive; + final PromptToSnapshotBloc promptToSnapshotBloc; - const CreateSnapshotDialog({super.key, required this.drive}); + const CreateSnapshotDialog({ + super.key, + required this.drive, + required this.promptToSnapshotBloc, + }); @override Widget build(BuildContext context) { final createSnapshotCubit = context.read(); - return BlocBuilder( + return BlocConsumer( + listener: (context, state) { + if (state is SnapshotUploadSuccess) { + promptToSnapshotBloc.add( + DriveSnapshotted( + driveId: drive.id, + // TODO + /// txsSyncedWithGqlCount: state.notSnapshottedTxsCount, + ), + ); + } + }, builder: (context, state) { if (state is CreateSnapshotInitial) { return _explanationDialog(context, drive); @@ -90,7 +114,7 @@ Widget _explanationDialog(BuildContext context, Drive drive) { final createSnapshotCubit = context.read(); return ArDriveStandardModal( - title: appLocalizationsOf(context).createSnapshot, + title: appLocalizationsOf(context).newSnapshot, content: SizedBox( width: kMediumDialogWidth, child: Row( @@ -340,7 +364,7 @@ Widget _confirmDialog( CreateSnapshotState state, ) { return ArDriveStandardModal( - title: appLocalizationsOf(context).createSnapshot, + title: appLocalizationsOf(context).newSnapshot, content: SizedBox( width: kMediumDialogWidth, child: Row( diff --git a/lib/components/new_button/new_button.dart b/lib/components/new_button/new_button.dart index 1d5458508d..1b7147aad8 100644 --- a/lib/components/new_button/new_button.dart +++ b/lib/components/new_button/new_button.dart @@ -8,6 +8,7 @@ import 'package:ardrive/components/pin_file_dialog.dart'; import 'package:ardrive/components/upload_form.dart'; import 'package:ardrive/models/daos/daos.dart'; import 'package:ardrive/models/database/database.dart'; +import 'package:ardrive/models/enums.dart'; import 'package:ardrive/pages/drive_detail/components/dropdown_item.dart'; import 'package:ardrive/services/services.dart'; import 'package:ardrive/utils/app_localizations_wrapper.dart'; @@ -331,6 +332,12 @@ class NewButton extends StatelessWidget { promptToCreateManifest( context, drive: drive!, + // TODO: for big drives, this might will be slow + hasPendingFiles: driveDetailState.currentFolderContents.any( + (element) => + element.fileStatusFromTransactions == + TransactionStatus.pending, + ), ); }, isDisabled: !driveDetailState.hasWritePermissions || @@ -349,7 +356,7 @@ class NewButton extends StatelessWidget { }, isDisabled: !driveDetailState.hasWritePermissions || driveDetailState.driveIsEmpty, - name: appLocalizations.createSnapshot, + name: appLocalizations.newSnapshot, icon: ArDriveIcons.iconCreateSnapshot(size: defaultIconSize), ), ] diff --git a/lib/components/prompt_to_snapshot_dialog.dart b/lib/components/prompt_to_snapshot_dialog.dart new file mode 100644 index 0000000000..909f2422bd --- /dev/null +++ b/lib/components/prompt_to_snapshot_dialog.dart @@ -0,0 +1,87 @@ +import 'package:ardrive/blocs/prompt_to_snapshot/prompt_to_snapshot_bloc.dart'; +import 'package:ardrive/blocs/prompt_to_snapshot/prompt_to_snapshot_event.dart'; +import 'package:ardrive/components/create_snapshot_dialog.dart'; +import 'package:ardrive/models/database/database.dart'; +import 'package:ardrive/theme/theme.dart'; +import 'package:ardrive/utils/app_localizations_wrapper.dart'; +import 'package:ardrive/utils/show_general_dialog.dart'; +import 'package:ardrive_ui/ardrive_ui.dart'; +import 'package:flutter/material.dart'; + +Future promptToSnapshot( + BuildContext context, { + required Drive drive, + required PromptToSnapshotBloc promptToSnapshotBloc, +}) async { + return showArDriveDialog( + context, + content: PromptToSnapshotDialog( + promptToSnapshotBloc: promptToSnapshotBloc, + drive: drive, + ), + ).then((value) => promptToSnapshotBloc.add(const ClosePromptToSnapshot())); +} + +class PromptToSnapshotDialog extends StatefulWidget { + final PromptToSnapshotBloc promptToSnapshotBloc; + + final Drive drive; + + const PromptToSnapshotDialog({ + super.key, + required this.drive, + required this.promptToSnapshotBloc, + }); + + @override + PromptToSnapshotDialogState createState() => PromptToSnapshotDialogState(); +} + +class PromptToSnapshotDialogState extends State { + bool _dontAskAgain = false; + + @override + Widget build(BuildContext context) { + return ArDriveStandardModal( + hasCloseButton: true, + title: _dontAskAgain + ? appLocalizationsOf(context).weWontRemindYou + : appLocalizationsOf(context).snapshotRecommended, + content: SizedBox( + width: kMediumDialogWidth, + child: Text( + _dontAskAgain + ? appLocalizationsOf(context).snapshotRecommendedDontAskAgain + : appLocalizationsOf(context).snapshotRecommendedBody, + style: ArDriveTypography.body.buttonNormalRegular(), + ), + ), + actions: [ + ModalAction( + action: () { + if (_dontAskAgain) { + Navigator.of(context).pop(); + } else { + setState(() { + _dontAskAgain = true; + widget.promptToSnapshotBloc + .add(const DismissDontAskAgain(dontAskAgain: true)); + }); + } + }, + title: _dontAskAgain + ? appLocalizationsOf(context).okEmphasized + : appLocalizationsOf(context).dontAskMeAgain, + ), + if (!_dontAskAgain) + ModalAction( + action: () { + Navigator.of(context).pop(); + promptToCreateSnapshot(context, widget.drive); + }, + title: appLocalizationsOf(context).createSnapshot, + ), + ], + ); + } +} diff --git a/lib/components/top_up_dialog.dart b/lib/components/top_up_dialog.dart index 2d1421f91a..4aebf652fe 100644 --- a/lib/components/top_up_dialog.dart +++ b/lib/components/top_up_dialog.dart @@ -459,7 +459,7 @@ class _PresetAmountSelectorState extends State { const SizedBox(height: 8), Row( children: [ - _textField(textTheme), + _customAmountTextField(textTheme), if (_customAmountValidationMessage != null && _customAmountValidationMessage!.isNotEmpty) ...[ const SizedBox(width: 8), @@ -491,7 +491,7 @@ class _PresetAmountSelectorState extends State { ), ), const SizedBox(height: 8), - _textField(textTheme), + _customAmountTextField(textTheme), ], ), if (_customAmountValidationMessage != null && @@ -516,7 +516,7 @@ class _PresetAmountSelectorState extends State { ); } - Widget _textField(textTheme) { + Widget _customAmountTextField(textTheme) { return SizedBox( key: const ValueKey('custom_amount_text_field'), width: 114, diff --git a/lib/components/upload_form.dart b/lib/components/upload_form.dart index 33598b9ad8..80c46fe59f 100644 --- a/lib/components/upload_form.dart +++ b/lib/components/upload_form.dart @@ -31,6 +31,7 @@ import 'package:ardrive/utils/upload_plan_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'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -48,17 +49,24 @@ Future promptToUpload( }) async { final selectedFiles = []; final io = ArDriveIO(); - IOFolder? ioFolder; + final IOFolder? ioFolder; if (isFolderUpload) { ioFolder = await io.pickFolder(); final ioFiles = await ioFolder.listFiles(); - final uploadFiles = ioFiles.map((file) { - return UploadFile( - ioFile: file, - parentFolderId: parentFolderId, - relativeTo: ioFolder!.path.isEmpty ? null : getDirname(ioFolder.path), - ); - }).toList(); + + final isMobilePlatform = AppPlatform.isMobile; + final shouldUseRelativePath = isMobilePlatform && ioFolder.path.isNotEmpty; + final relativeTo = shouldUseRelativePath ? getDirname(ioFolder.path) : null; + + final uploadFiles = ioFiles + .map( + (file) => UploadFile( + ioFile: file, + parentFolderId: parentFolderId, + relativeTo: relativeTo, + ), + ) + .toList(); selectedFiles.addAll(uploadFiles); } else { // Display multiple options on Mobile @@ -67,12 +75,11 @@ Future promptToUpload( ? await io.pickFiles(fileSource: FileSource.fileSystem) // ignore: use_build_context_synchronously : await showMultipleFilesFilePickerModal(context); - final uploadFiles = ioFiles .map((file) => UploadFile(ioFile: file, parentFolderId: parentFolderId)) .toList(); - selectedFiles.addAll(uploadFiles); + ioFolder = null; } // ignore: use_build_context_synchronously diff --git a/lib/entities/manifest_data.dart b/lib/entities/manifest_data.dart index 4b8eb2ceff..c9c74c90af 100644 --- a/lib/entities/manifest_data.dart +++ b/lib/entities/manifest_data.dart @@ -1,12 +1,13 @@ import 'dart:convert'; -import 'dart:typed_data'; import 'package:ardrive/entities/entities.dart'; -import 'package:ardrive/models/daos/drive_dao/drive_dao.dart'; +import 'package:ardrive/models/models.dart'; import 'package:ardrive_utils/ardrive_utils.dart'; import 'package:arweave/arweave.dart'; import 'package:collection/collection.dart'; -import 'package:json_annotation/json_annotation.dart'; +import 'package:drift/drift.dart' show Uint8List; +import 'package:json_annotation/json_annotation.dart' + show JsonKey, JsonSerializable; import 'package:package_info_plus/package_info_plus.dart'; part 'manifest_data.g.dart'; @@ -27,8 +28,13 @@ class ManifestIndex { class ManifestPath { @JsonKey() final String id; + @JsonKey(includeFromJson: false, includeToJson: false) + final String? fileId; - ManifestPath(this.id); + ManifestPath( + this.id, { + this.fileId, + }); factory ManifestPath.fromJson(Map json) => _$ManifestPathFromJson(json); @@ -46,7 +52,10 @@ class ManifestData { @JsonKey() final Map paths; - ManifestData(this.index, this.paths); + ManifestData( + this.index, + this.paths, + ); int get size => jsonData.lengthInBytes; Uint8List get jsonData => utf8.encode(json.encode(this)) as Uint8List; @@ -64,7 +73,9 @@ class ManifestData { return manifestDataItem; } - static ManifestData fromFolderNode({required FolderNode folderNode}) { + static ManifestData fromFolderNode({ + required FolderNode folderNode, + }) { final fileList = folderNode .getRecursiveFiles() // We will not include any existing manifests in the new manifest @@ -93,11 +104,15 @@ class ManifestData { final paths = { for (final file in fileList) prepareManifestPath( - filePath: file.path, - rootFolderPath: rootFolderPath): ManifestPath(file.dataTxId) + filePath: file.path, + rootFolderPath: rootFolderPath, + ): ManifestPath(file.dataTxId, fileId: file.id) }; - return ManifestData(index, paths); + return ManifestData( + index, + paths, + ); } factory ManifestData.fromJson(Map json) => @@ -107,7 +122,9 @@ class ManifestData { /// Utility function to remove base path of the target folder and /// replace spaces with underscores for arweave.net URL compatibility -String prepareManifestPath( - {required String filePath, required String rootFolderPath}) { +String prepareManifestPath({ + required String filePath, + required String rootFolderPath, +}) { return filePath.substring(rootFolderPath.length + 1).replaceAll(' ', '_'); } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index d24ce66e34..afed70e078 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -798,6 +798,14 @@ "@fileSize": { "description": "The size of a file" }, + "filesPending": "Files Pending", + "@filesPending": { + "description": "E.g. \"there are some files pending\"" + }, + "filesPendingManifestExplanation": "Some of the files in this folder are still pending. We do not recommend creating a manifest with pending files. Would you like to proceed?", + "@filesPendingManifestExplanation": { + "description": "Warns the user about creating manifests with pending files" + }, "filesTooLarge": "{numberOfFiles, plural, zero{} one{1 file too large} other{{numberOfFiles} files too large}}", "@filesTooLarge": { "description": "A single or many files are too large to upload", diff --git a/lib/main.dart b/lib/main.dart index 09677d2301..e9546056c0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:ardrive/authentication/ardrive_auth.dart'; import 'package:ardrive/blocs/activity/activity_cubit.dart'; import 'package:ardrive/blocs/feedback_survey/feedback_survey_cubit.dart'; +import 'package:ardrive/blocs/prompt_to_snapshot/prompt_to_snapshot_bloc.dart'; import 'package:ardrive/blocs/upload/limits.dart'; import 'package:ardrive/blocs/upload/upload_file_checker.dart'; import 'package:ardrive/components/keyboard_handler.dart'; @@ -26,11 +27,10 @@ import 'package:ardrive/utils/pre_cache_assets.dart'; import 'package:ardrive/utils/secure_key_value_store.dart'; import 'package:ardrive_http/ardrive_http.dart'; import 'package:ardrive_io/ardrive_io.dart'; +import 'package:ardrive_logger/ardrive_logger.dart'; import 'package:ardrive_ui/ardrive_ui.dart'; import 'package:ardrive_utils/ardrive_utils.dart'; import 'package:arweave/arweave.dart'; -import 'package:firebase_core/firebase_core.dart'; -import 'package:firebase_crashlytics/firebase_crashlytics.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -41,10 +41,10 @@ import 'package:flutter_portal/flutter_portal.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:local_auth/local_auth.dart'; import 'package:provider/provider.dart'; +import 'package:provider/single_child_widget.dart'; import 'package:pst/pst.dart'; import 'blocs/blocs.dart'; -import 'firebase_options.dart'; import 'models/models.dart'; import 'pages/pages.dart'; import 'services/services.dart'; @@ -56,12 +56,38 @@ late ConfigService configService; late ArweaveService _arweave; late TurboUploadService _turboUpload; late PaymentService _turboPayment; + void main() async { - WidgetsFlutterBinding.ensureInitialized(); + await runZonedGuarded(() async { + WidgetsFlutterBinding.ensureInitialized(); - MobileStatusBar.show(); - MobileScreenOrientation.lockInPortraitUp(); + await _initializeServices(); + + await _startApp(); + }, (error, stackTrace) { + logger.e('Error caught.', error, stackTrace); + }); +} + +Future _startApp() async { + final flavor = await configService.loadAppFlavor(); + flavor == Flavor.staging || flavor == Flavor.production + ? _runWithSentryLogging() + : _runWithoutLogging(); +} + +Future _runWithoutLogging() async { + runApp(const App()); +} + +Future _runWithSentryLogging() async { + await initSentry(); + + runApp(const App()); +} + +Future _initializeServices() async { final localStore = await LocalKeyValueStore.getInstance(); await AppInfoServices().loadAppInfo(); @@ -71,38 +97,20 @@ void main() async { configFetcher: ConfigFetcher(localStore: localStore), ); - await configService.loadConfig(); - - final flavor = await configService.loadAppFlavor(); - - if (!kIsWeb) { - if (flavor == Flavor.development) { - _runWithCrashlytics(flavor.name); - return; - } - } - - logger.d('Running without crashlytics for $flavor'); - - _runWithoutCrashlytics(); -} - -Future _runWithoutCrashlytics() async { - await _initialize(); - runApp(const App()); -} + MobileStatusBar.show(); + MobileScreenOrientation.lockInPortraitUp(); + ArDriveMobileDownloader.initialize(); -Future _initialize() async { SystemChrome.setSystemUIOverlayStyle( const SystemUiOverlayStyle(statusBarBrightness: Brightness.light), ); + await configService.loadConfig(); + final config = configService.config; logger.d('Initializing app with config: $config'); - ArDriveMobileDownloader.initialize(); - _arweave = ArweaveService( Arweave( gatewayUrl: Uri.parse(config.defaultArweaveGatewayUrl!), @@ -124,39 +132,10 @@ Future _initialize() async { ); if (kIsWeb) { - refreshHTMLPageAtInterval(const Duration(hours: 12)); + _refreshHTMLPageAtInterval(const Duration(hours: 12)); } } -Future _runWithCrashlytics(String flavor) async { - runZonedGuarded>( - () async { - await Firebase.initializeApp( - options: DefaultFirebaseOptions.currentPlatform, - ); - await _initialize(); - - FirebaseCrashlytics.instance - .log('Starting application with crashlytics for $flavor'); - - // Pass all uncaught errors from the framework to Crashlytics. - FlutterError.onError = - FirebaseCrashlytics.instance.recordFlutterFatalError; - - runApp(const App()); - }, - (error, stack) => FirebaseCrashlytics.instance.recordError( - error, - stack, - fatal: true, - ), - ); -} - -void refreshHTMLPageAtInterval(Duration duration) { - Timer.periodic(duration, (timer) => triggerHTMLPageReload()); -} - class App extends StatefulWidget { const App({Key? key}) : super(key: key); @@ -180,9 +159,117 @@ class AppState extends State { @override Widget build(BuildContext context) { return MultiRepositoryProvider( - providers: [ + providers: repositoryProviders, + child: ArDriveDevToolsShortcuts( + child: KeyboardHandler( + child: MultiBlocProvider( + providers: blocProviders, + child: BlocConsumer( + listener: (context, state) { + if (state is ThemeSwitcherDarkTheme) { + ArDriveUIThemeSwitcher.changeTheme(ArDriveThemes.dark); + } else if (state is ThemeSwitcherLightTheme) { + ArDriveUIThemeSwitcher.changeTheme(ArDriveThemes.light); + } + }, + builder: (context, state) { + return ArDriveApp( + onThemeChanged: (theme) { + context.read().add(ChangeTheme()); + }, + key: arDriveAppKey, + builder: _appBuilder, + ); + }, + ), + ), + ), + ), + ); + } + + MaterialApp _appBuilder(BuildContext context) { + final ardriveTheme = + ArDriveTheme.of(context).themeData.materialThemeData.copyWith( + scaffoldBackgroundColor: + ArDriveTheme.of(context).themeData.backgroundColor, + ); + + return MaterialApp.router( + title: _appName, + theme: ardriveTheme, + debugShowCheckedModeBanner: false, + routeInformationParser: _routeInformationParser, + routerDelegate: _routerDelegate, + localizationsDelegates: _localizationsDelegates, + supportedLocales: _locales, + + // TODO: Remove this once we have a proper solution for + builder: (context, child) => ListTileTheme( + textColor: kOnSurfaceBodyTextColor, + iconColor: kOnSurfaceBodyTextColor, + child: Portal( + child: child!, + ), + ), + ); + } + + static const String _appName = 'ArDrive'; + + Iterable get _locales => const [ + Locale('en', ''), // English, no country code + Locale('es', ''), // Spanish, no country code + Locale.fromSubtags(languageCode: 'zh'), // generic Chinese 'zh' + Locale.fromSubtags( + languageCode: 'zh', + countryCode: 'HK', + ), // Traditional Chinese, Cantonese + Locale('ja', ''), // Japanese, no country code + Locale('hi', ''), // Hindi, no country code + ]; + + Iterable get _localizationsDelegates => const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ]; + + List get blocProviders => [ ChangeNotifierProvider( create: (_) => ActivityTracker()), + BlocProvider( + create: (context) => ThemeSwitcherBloc( + userPreferencesRepository: + context.read(), + )..add(LoadTheme()), + ), + BlocProvider( + create: (context) => ProfileCubit( + arweave: context.read(), + turboUploadService: context.read(), + profileDao: context.read(), + db: context.read(), + tabVisibilitySingleton: TabVisibilitySingleton(), + ), + ), + BlocProvider( + create: (context) => ActivityCubit(), + ), + BlocProvider( + create: (context) => + FeedbackSurveyCubit(FeedbackSurveyInitialState()), + ), + BlocProvider( + create: (context) => PromptToSnapshotBloc( + userRepository: context.read(), + profileCubit: context.read(), + driveDao: context.read(), + ), + ), + ]; + + List get repositoryProviders => [ RepositoryProvider(create: (_) => _arweave), // repository provider for UploadFileChecker RepositoryProvider( @@ -255,92 +342,9 @@ class AppState extends State { themeDetector: ThemeDetector(), ), ), - ], - child: ArDriveDevToolsShortcuts( - child: KeyboardHandler( - child: MultiBlocProvider( - providers: [ - BlocProvider( - create: (context) => ThemeSwitcherBloc( - userPreferencesRepository: - context.read(), - )..add(LoadTheme()), - ), - BlocProvider( - create: (context) => ProfileCubit( - arweave: context.read(), - turboUploadService: context.read(), - profileDao: context.read(), - db: context.read(), - tabVisibilitySingleton: TabVisibilitySingleton(), - ), - ), - BlocProvider( - create: (context) => ActivityCubit(), - ), - BlocProvider( - create: (context) => - FeedbackSurveyCubit(FeedbackSurveyInitialState()), - ), - ], - child: BlocConsumer( - listener: (context, state) { - if (state is ThemeSwitcherDarkTheme) { - ArDriveUIThemeSwitcher.changeTheme(ArDriveThemes.dark); - } else if (state is ThemeSwitcherLightTheme) { - ArDriveUIThemeSwitcher.changeTheme(ArDriveThemes.light); - } - }, - builder: (context, state) { - return ArDriveApp( - onThemeChanged: (theme) { - context.read().add(ChangeTheme()); - }, - key: arDriveAppKey, - builder: (context) => MaterialApp.router( - title: 'ArDrive', - theme: ArDriveTheme.of(context) - .themeData - .materialThemeData - .copyWith( - scaffoldBackgroundColor: ArDriveTheme.of(context) - .themeData - .backgroundColor, - ), - debugShowCheckedModeBanner: false, - routeInformationParser: _routeInformationParser, - routerDelegate: _routerDelegate, - localizationsDelegates: const [ - AppLocalizations.delegate, - GlobalMaterialLocalizations.delegate, - GlobalWidgetsLocalizations.delegate, - ], - supportedLocales: const [ - Locale('en', ''), // English, no country code - Locale('es', ''), // Spanish, no country code - Locale.fromSubtags( - languageCode: 'zh'), // generic Chinese 'zh' - Locale.fromSubtags( - languageCode: 'zh', - countryCode: 'HK', - ), // Traditional Chinese, Cantonese - Locale('ja', ''), // Japanese, no country code - Locale('hi', ''), // Hindi, no country code - ], - builder: (context, child) => ListTileTheme( - textColor: kOnSurfaceBodyTextColor, - iconColor: kOnSurfaceBodyTextColor, - child: Portal( - child: child!, - ), - ), - ), - ); - }, - ), - ), - ), - ), - ); - } + ]; +} + +void _refreshHTMLPageAtInterval(Duration duration) { + Timer.periodic(duration, (timer) => triggerHTMLPageReload()); } diff --git a/lib/models/queries/drive_queries.drift b/lib/models/queries/drive_queries.drift index 1a38a6d2ce..392fb349ed 100644 --- a/lib/models/queries/drive_queries.drift +++ b/lib/models/queries/drive_queries.drift @@ -166,6 +166,13 @@ pendingTransactions: SELECT * FROM network_transactions WHERE status = 'pending'; +pendingTransactionsForDrive: + SELECT * FROM network_transactions + WHERE status = 'pending' AND id IN ( + SELECT metadataTxId FROM file_revisions + WHERE driveId = :driveId + ); + deleteDriveById: DELETE FROM drives WHERE id = :driveId; deleteAllDriveRevisionsByDriveId: DELETE FROM drive_revisions diff --git a/lib/pages/app_router_delegate.dart b/lib/pages/app_router_delegate.dart index c5f8036794..a1ffb57a57 100644 --- a/lib/pages/app_router_delegate.dart +++ b/lib/pages/app_router_delegate.dart @@ -4,6 +4,7 @@ import 'package:ardrive/authentication/login/views/login_page.dart'; import 'package:ardrive/blocs/activity/activity_cubit.dart'; import 'package:ardrive/blocs/blocs.dart'; import 'package:ardrive/blocs/feedback_survey/feedback_survey_cubit.dart'; +import 'package:ardrive/blocs/prompt_to_snapshot/prompt_to_snapshot_bloc.dart'; import 'package:ardrive/components/components.dart'; import 'package:ardrive/components/feedback_survey.dart'; import 'package:ardrive/core/activity_tracker.dart'; @@ -280,6 +281,7 @@ class AppRouterDelegate extends RouterDelegate configService: context.read(), profileCubit: context.read(), activityCubit: context.read(), + promptToSnapshotBloc: context.read(), arweave: context.read(), driveDao: context.read(), db: context.read(), @@ -293,6 +295,7 @@ class AppRouterDelegate extends RouterDelegate initialSelectedDriveId: driveId, profileCubit: context.read(), driveDao: context.read(), + promptToSnapshotBloc: context.read(), ), ), ], 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 36e317d832..62f8dd6fa8 100644 --- a/lib/pages/drive_detail/components/drive_detail_data_list.dart +++ b/lib/pages/drive_detail/components/drive_detail_data_list.dart @@ -147,8 +147,12 @@ Widget _buildDataListContent( bool isMultiselecting, ) { return LayoutBuilder(builder: (context, constraints) { + final driveDetailCubitState = context.read().state; + final forceRebuildKey = driveDetailCubitState is DriveDetailLoadSuccess + ? driveDetailCubitState.forceRebuildKey + : null; return ArDriveDataTable( - key: ValueKey(folder.id), + key: ValueKey(folder.id + forceRebuildKey.toString()), lockMultiSelect: context.watch().state is SyncInProgress || !context.watch().isMultiSelectEnabled, rowsPerPageText: appLocalizationsOf(context).rowsPerPage, diff --git a/lib/pages/drive_detail/drive_detail_page.dart b/lib/pages/drive_detail/drive_detail_page.dart index 51891c632e..e76dfd7424 100644 --- a/lib/pages/drive_detail/drive_detail_page.dart +++ b/lib/pages/drive_detail/drive_detail_page.dart @@ -5,6 +5,9 @@ import 'package:ardrive/app_shell.dart'; import 'package:ardrive/authentication/ardrive_auth.dart'; import 'package:ardrive/blocs/blocs.dart'; import 'package:ardrive/blocs/fs_entry_preview/fs_entry_preview_cubit.dart'; +import 'package:ardrive/blocs/prompt_to_snapshot/prompt_to_snapshot_bloc.dart'; +import 'package:ardrive/blocs/prompt_to_snapshot/prompt_to_snapshot_event.dart'; +import 'package:ardrive/blocs/prompt_to_snapshot/prompt_to_snapshot_state.dart'; import 'package:ardrive/components/app_bottom_bar.dart'; import 'package:ardrive/components/app_top_bar.dart'; import 'package:ardrive/components/components.dart'; @@ -13,6 +16,7 @@ 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/new_button/new_button.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/download/multiple_file_download_modal.dart'; @@ -108,35 +112,37 @@ class _DriveDetailPageState extends State { @override Widget build(BuildContext context) { return SizedBox.expand( - 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(), - ), - ), - ), - ); - }, - desktop: (context) => Scaffold( - drawerScrimColor: Colors.transparent, - body: Column( - children: [ - const AppTopBar(), - Expanded( + child: BlocListener( + listener: (context, state) { + if (state is PromptToSnapshotPrompting) { + final bloc = context.read(); + + final driveDetailState = context.read().state; + if (driveDetailState is DriveDetailLoadSuccess) { + final drive = driveDetailState.currentDrive; + promptToSnapshot( + context, + promptToSnapshotBloc: bloc, + drive: drive, + ).then((_) { + bloc.add(const SelectedDrive(driveId: null)); + }); + } + } + }, + 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) @@ -145,60 +151,77 @@ class _DriveDetailPageState extends State { ), ), ), - ], + ); + }, + desktop: (context) => Scaffold( + drawerScrimColor: Colors.transparent, + body: Column( + children: [ + const AppTopBar(), + Expanded( + child: Center( + child: Text( + appLocalizationsOf(context) + .driveDoingInitialSetupMessage, + style: ArDriveTypography.body.buttonLargeBold(), + ), + ), + ), + ], + ), ), - ), - ); - } else if (driveDetailState is DriveDetailLoadSuccess) { - final hasSubfolders = - driveDetailState.folderInView.subfolders.isNotEmpty; + ); + } else if (driveDetailState is DriveDetailLoadSuccess) { + final hasSubfolders = + driveDetailState.folderInView.subfolders.isNotEmpty; - final isOwner = isDriveOwner( - context.read(), - driveDetailState.currentDrive.ownerAddress, - ); + final isOwner = isDriveOwner( + context.read(), + driveDetailState.currentDrive.ownerAddress, + ); - final hasFiles = driveDetailState.folderInView.files.isNotEmpty; + final hasFiles = driveDetailState.folderInView.files.isNotEmpty; - 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( - 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, - driveDetailState.currentFolderContents, + return ScreenTypeLayout.builder( + desktop: (context) => _desktopView( + isDriveOwner: isOwner, + driveDetailState: driveDetailState, + hasSubfolders: hasSubfolders, + hasFiles: hasFiles, + canDownloadMultipleFiles: canDownloadMultipleFiles, ), - ), - ); - } else { - return const SizedBox(); - } - }, + mobile: (context) => Scaffold( + 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, + driveDetailState.currentFolderContents, + ), + ), + ); + } else { + return const SizedBox(); + } + }, + ), ), ); } diff --git a/lib/utils/debouncer.dart b/lib/utils/debouncer.dart new file mode 100644 index 0000000000..b200a8e92a --- /dev/null +++ b/lib/utils/debouncer.dart @@ -0,0 +1,30 @@ +import 'dart:async'; + +class Debouncer { + final Duration delay; + Timer? _timer; + Completer? _completer; + + Debouncer({ + this.delay = const Duration(milliseconds: 500), + }); + + Future run(Future Function() action) { + _timer?.cancel(); + _completer?.completeError('Cancelled'); + _completer = Completer(); + _timer = Timer(delay, () { + action().whenComplete(() { + _completer?.complete(); + _completer = null; + }); + }); + return _completer!.future; + } + + void cancel() { + _timer?.cancel(); + _completer?.completeError('Cancelled'); + _completer = null; + } +} diff --git a/lib/utils/snapshots/gql_drive_history.dart b/lib/utils/snapshots/gql_drive_history.dart index e0ee64e220..6307f0a0be 100644 --- a/lib/utils/snapshots/gql_drive_history.dart +++ b/lib/utils/snapshots/gql_drive_history.dart @@ -10,6 +10,8 @@ class GQLDriveHistory implements SegmentedGQLData { final DriveID driveId; final String ownerAddress; + int _txCount = 0; + int _currentIndex = -1; final ArweaveService _arweave; @@ -18,6 +20,8 @@ class GQLDriveHistory implements SegmentedGQLData { @override int get currentIndex => _currentIndex; + int get txCount => _txCount; + GQLDriveHistory({ required this.subRanges, required ArweaveService arweave, @@ -49,6 +53,7 @@ class GQLDriveHistory implements SegmentedGQLData { await for (final multipleEdges in txsStream) { for (final edge in multipleEdges) { + _txCount++; yield edge.node; } } diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index ada08ba427..48d4b59404 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -17,6 +17,7 @@ import flutter_secure_storage_macos import just_audio import package_info_plus import path_provider_foundation +import sentry_flutter import share_plus import shared_preferences_foundation import sqflite @@ -37,6 +38,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin")) FLTPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FLTPackageInfoPlusPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + SentryFlutterPlugin.register(with: registry.registrar(forPlugin: "SentryFlutterPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) diff --git a/packages/ardrive_logger/lib/ardrive_logger.dart b/packages/ardrive_logger/lib/ardrive_logger.dart index 83ab0d8b8f..677bed3f60 100644 --- a/packages/ardrive_logger/lib/ardrive_logger.dart +++ b/packages/ardrive_logger/lib/ardrive_logger.dart @@ -1,3 +1,4 @@ library ardrive_logger; export 'src/logger.dart'; +export 'src/sentry.dart'; diff --git a/packages/ardrive_logger/lib/src/logger.dart b/packages/ardrive_logger/lib/src/logger.dart index 0850a30765..f6854b013a 100644 --- a/packages/ardrive_logger/lib/src/logger.dart +++ b/packages/ardrive_logger/lib/src/logger.dart @@ -6,6 +6,7 @@ import 'dart:convert'; import 'package:ardrive_io/ardrive_io.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_email_sender/flutter_email_sender.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:share_plus/share_plus.dart'; Future _convertTextToIOFile({ @@ -75,6 +76,7 @@ class Logger { } log(LogLevel.error, errorMessage); + Sentry.captureException(error ?? message, stackTrace: stackTrace); } void log(LogLevel level, String message) { diff --git a/packages/ardrive_logger/lib/src/sentry.dart b/packages/ardrive_logger/lib/src/sentry.dart new file mode 100644 index 0000000000..e45b48ea82 --- /dev/null +++ b/packages/ardrive_logger/lib/src/sentry.dart @@ -0,0 +1,28 @@ +import 'dart:async'; + +import 'package:sentry_flutter/sentry_flutter.dart'; + +FutureOr _beforeSend(SentryEvent event, {Hint? hint}) async { + event = event.copyWith( + user: SentryUser( + id: null, + username: null, + email: null, + ipAddress: null, + geo: null, + name: null, + data: null, + ), + ); + + return event; +} + +Future initSentry() async { + await SentryFlutter.init( + (options) { + options.beforeSend = _beforeSend; + options.tracesSampleRate = 1.0; + }, + ); +} diff --git a/packages/ardrive_logger/pubspec.yaml b/packages/ardrive_logger/pubspec.yaml index 759626fc09..7d5e285f3c 100644 --- a/packages/ardrive_logger/pubspec.yaml +++ b/packages/ardrive_logger/pubspec.yaml @@ -18,6 +18,7 @@ dependencies: share_plus: ^7.2.1 flutter_email_sender: ^6.0.2 mocktail: ^1.0.2 + sentry_flutter: ^7.14.0 dev_dependencies: diff --git a/packages/ardrive_uploader/lib/src/ardrive_uploader.dart b/packages/ardrive_uploader/lib/src/ardrive_uploader.dart index 901b210684..f756cb13b9 100644 --- a/packages/ardrive_uploader/lib/src/ardrive_uploader.dart +++ b/packages/ardrive_uploader/lib/src/ardrive_uploader.dart @@ -130,7 +130,7 @@ class _ArDriveUploader implements ArDriveUploader { final metadata = await _metadataGenerator.generateMetadata( file, - args, + arguments: args, ); final uploadTask = FileUploadTask( @@ -188,7 +188,7 @@ class _ArDriveUploader implements ArDriveUploader { final metadata = await _metadataGenerator.generateMetadata( ioFile, - metadataArgs, + arguments: metadataArgs, ); final fileTask = FileUploadTask( @@ -245,7 +245,7 @@ class _ArDriveUploader implements ArDriveUploader { for (var e in entities) { final metadata = await _metadataGenerator.generateMetadata( e.$2, - e.$1, + arguments: e.$1, ); if (metadata is ARFSFolderUploadMetatadata) { diff --git a/packages/ardrive_uploader/lib/src/data_bundler.dart b/packages/ardrive_uploader/lib/src/data_bundler.dart index 5150b294bf..984750ade1 100644 --- a/packages/ardrive_uploader/lib/src/data_bundler.dart +++ b/packages/ardrive_uploader/lib/src/data_bundler.dart @@ -169,14 +169,15 @@ class DataTransactionBundler implements DataBundler { required String driveId, }) async { if (entity is IOFile) { + final args = ARFSUploadMetadataArgs( + isPrivate: driveKey != null, + driveId: driveId, + parentFolderId: metadata.id, + type: this is BDIDataBundler ? UploadType.turbo : UploadType.d2n, + ); final fileMetadata = await metadataGenerator.generateMetadata( entity, - ARFSUploadMetadataArgs( - isPrivate: driveKey != null, - driveId: driveId, - parentFolderId: metadata.id, - type: this is BDIDataBundler ? UploadType.turbo : UploadType.d2n, - ), + arguments: args, ); return DataResultWithContents( diff --git a/packages/ardrive_uploader/lib/src/metadata_generator.dart b/packages/ardrive_uploader/lib/src/metadata_generator.dart index cc2e2c5b7e..cb826db3de 100644 --- a/packages/ardrive_uploader/lib/src/metadata_generator.dart +++ b/packages/ardrive_uploader/lib/src/metadata_generator.dart @@ -1,11 +1,11 @@ import 'package:ardrive_io/ardrive_io.dart'; import 'package:ardrive_uploader/ardrive_uploader.dart'; +import 'package:ardrive_uploader/src/constants.dart'; import 'package:ardrive_utils/ardrive_utils.dart'; import 'package:arfs/arfs.dart'; import 'package:arweave/arweave.dart'; import 'package:equatable/equatable.dart'; import 'package:uuid/uuid.dart'; -import 'package:ardrive_uploader/src/constants.dart'; /// this class will get an `IOFile` and generate the metadata for it /// @@ -13,7 +13,7 @@ import 'package:ardrive_uploader/src/constants.dart'; /// /// `T` is the type of the metadata that will be generated abstract class UploadMetadataGenerator { - Future generateMetadata(IOEntity entity, [A arguments]); + Future generateMetadata(IOEntity entity, {required A arguments}); } abstract class TagsGenerator { @@ -40,13 +40,11 @@ class ARFSUploadMetadataGenerator final ARFSTagsGenetator _tagsGenerator; @override - Future generateMetadata(IOEntity entity, - [ARFSUploadMetadataArgs? arguments]) async { - if (arguments == null) { - throw ArgumentError('arguments must not be null'); - } - - String id; + Future generateMetadata( + IOEntity entity, { + required ARFSUploadMetadataArgs arguments, + }) async { + final String id; if (arguments.entityId != null) { id = arguments.entityId!; @@ -54,7 +52,7 @@ class ARFSUploadMetadataGenerator id = const Uuid().v4(); } - String contentType; + final String contentType; if (arguments.isPrivate) { contentType = 'application/octet-stream'; @@ -72,12 +70,14 @@ class ARFSUploadMetadataGenerator final file = entity; - List? customBundleTags; + final List? customBundleTags; /// If the file is a D2N file, we need to add the $U tags to the /// bundle tags if (arguments.type == UploadType.d2n) { customBundleTags = _uTags; + } else { + customBundleTags = null; } final tags = _tagsGenerator.generateTags( @@ -146,7 +146,7 @@ class ARFSUploadMetadataGenerator }) async { final id = const Uuid().v4(); - String contentType; + final String contentType; if (isPrivate) { contentType = 'application/octet-stream'; @@ -203,6 +203,7 @@ class ARFSUploadMetadataArgs { required String driveId, required bool isPrivate, required UploadType type, + required String path, String? parentFolderId, String? entityId, }) { @@ -273,7 +274,7 @@ class ARFSTagsGenetator implements TagsGenerator { ) { ARFSTagsValidator.validate(arguments); - List tags = []; + final List tags = []; final driveId = Tag(EntityTag.driveId, arguments.driveId!); @@ -281,7 +282,7 @@ class ARFSTagsGenetator implements TagsGenerator { final appInfo = _appInfoServices.appInfo; - String contentType; + final String contentType; if (arguments.isPrivate!) { contentType = 'application/octet-stream'; diff --git a/packages/ardrive_uploader/test/metadata_generator_test.dart b/packages/ardrive_uploader/test/metadata_generator_test.dart index a7a2aa7a6c..054e31803c 100644 --- a/packages/ardrive_uploader/test/metadata_generator_test.dart +++ b/packages/ardrive_uploader/test/metadata_generator_test.dart @@ -53,16 +53,10 @@ void main() { group('ARFSUploadMetadataGenerator', () { group('generateMetadata for IOFile', () { - test('throws if arguments is null', () { - expect(() => metadataGenerator.generateMetadata(DumbIOFile()), - throwsA(isA())); - }); - test('throws if entity type is null', () { expect( - () => metadataGenerator.generateMetadata( - DumbIOFile(), - ARFSUploadMetadataArgs( + () => metadataGenerator.generateMetadata(DumbIOFile(), + arguments: ARFSUploadMetadataArgs( isPrivate: false, type: UploadType.d2n, entityId: null, @@ -72,9 +66,8 @@ void main() { test('throws if drive id is null', () { expect( - () => metadataGenerator.generateMetadata( - DumbIOFile(), - ARFSUploadMetadataArgs( + () => metadataGenerator.generateMetadata(DumbIOFile(), + arguments: ARFSUploadMetadataArgs( isPrivate: false, type: UploadType.d2n, entityId: 'entity123', @@ -85,9 +78,8 @@ void main() { test('throws if parentFolderId is null', () { expect( - () => metadataGenerator.generateMetadata( - DumbIOFile(), - ARFSUploadMetadataArgs( + () => metadataGenerator.generateMetadata(DumbIOFile(), + arguments: ARFSUploadMetadataArgs( isPrivate: false, type: UploadType.d2n, entityId: 'entity123', @@ -129,7 +121,7 @@ void main() { final metadata = await metadataGenerator.generateMetadata( DumbIOFile(), - ARFSUploadMetadataArgs( + arguments: ARFSUploadMetadataArgs( isPrivate: false, type: UploadType.d2n, entityId: 'entity123', @@ -196,7 +188,7 @@ void main() { final metadata = await metadataGenerator.generateMetadata( DumbIOFile(), - ARFSUploadMetadataArgs( + arguments: ARFSUploadMetadataArgs( isPrivate: true, // private file type: UploadType.d2n, entityId: 'entity123', @@ -257,7 +249,7 @@ void main() { final metadata = await metadataGenerator.generateMetadata( DumbIOFile(), - ARFSUploadMetadataArgs( + arguments: ARFSUploadMetadataArgs( isPrivate: false, type: UploadType.turbo, entityId: 'entity123', @@ -314,7 +306,7 @@ void main() { final metadata = await metadataGenerator.generateMetadata( DumbIOFile(), - ARFSUploadMetadataArgs( + arguments: ARFSUploadMetadataArgs( isPrivate: true, // private file type: UploadType.turbo, entityId: 'entity123', diff --git a/packages/pst/pubspec.lock b/packages/pst/pubspec.lock index d6f6d59ddf..3280096c79 100644 --- a/packages/pst/pubspec.lock +++ b/packages/pst/pubspec.lock @@ -1,22 +1,6 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: - _fe_analyzer_shared: - dependency: transitive - description: - name: _fe_analyzer_shared - sha256: ae92f5d747aee634b87f89d9946000c2de774be1d6ac3e58268224348cd0101a - url: "https://pub.dev" - source: hosted - version: "61.0.0" - analyzer: - dependency: transitive - description: - name: analyzer - sha256: ea3d8652bda62982addfd92fdc2d0214e5f82e43325104990d4f4c4a2a313562 - url: "https://pub.dev" - source: hosted - version: "5.13.0" ardrive_http: dependency: "direct main" description: @@ -33,23 +17,15 @@ packages: relative: true source: path version: "0.0.1" - args: - dependency: transitive - description: - name: args - sha256: eef6c46b622e0494a36c5a12d10d77fb4e855501a91c1b9ef9339326e58f0596 - url: "https://pub.dev" - source: hosted - version: "2.4.2" arweave: dependency: "direct main" description: path: "." - ref: "v3.8.2" - resolved-ref: "363d4b69be03d2e67390e7c70eb7c3a230377001" + ref: "v3.8.3" + resolved-ref: "41d590687cecafc316b3c83da20274a29d3e2833" url: "https://github.com/ardriveapp/arweave-dart.git" source: git - version: "3.8.2" + version: "3.8.3" async: dependency: transitive description: @@ -146,14 +122,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.1" - coverage: - dependency: transitive - description: - name: coverage - sha256: "595a29b55ce82d53398e1bcc2cba525d7bd7c59faeb2d2540e9d42c390cfeeeb" - url: "https://pub.dev" - source: hosted - version: "1.6.4" crypto: dependency: transitive description: @@ -298,22 +266,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" - frontend_server_client: - dependency: transitive - description: - name: frontend_server_client - sha256: "408e3ca148b31c20282ad6f37ebfa6f4bdc8fede5b74bc2f08d9d92b55db3612" - url: "https://pub.dev" - source: hosted - version: "3.2.0" - glob: - dependency: transitive - description: - name: glob - sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" - url: "https://pub.dev" - source: hosted - version: "2.1.2" hash: dependency: transitive description: @@ -354,14 +306,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" - http_multi_server: - dependency: transitive - description: - name: http_multi_server - sha256: "97486f20f9c2f7be8f514851703d0119c3596d14ea63227af6f7a481ef2b2f8b" - url: "https://pub.dev" - source: hosted - version: "3.2.1" http_parser: dependency: transitive description: @@ -370,14 +314,6 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.2" - io: - dependency: transitive - description: - name: io - sha256: "2ec25704aba361659e10e3e5f5d672068d332fc8ac516421d483a11e5cbd061e" - url: "https://pub.dev" - source: hosted - version: "1.0.4" isolated_worker: dependency: transitive description: @@ -418,14 +354,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.1" - logging: - dependency: transitive - description: - name: logging - sha256: "623a88c9594aa774443aa3eb2d41807a48486b5613e67599fb4c41c0ad47c340" - url: "https://pub.dev" - source: hosted - version: "1.2.0" matcher: dependency: transitive description: @@ -450,22 +378,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.0" - mime: - dependency: transitive - description: - name: mime - sha256: e4ff8e8564c03f255408decd16e7899da1733852a9110a58fe6d1b817684a63e - url: "https://pub.dev" - source: hosted - version: "1.0.4" mocktail: dependency: "direct main" description: name: mocktail - sha256: "80a996cd9a69284b3dc521ce185ffe9150cde69767c2d3a0720147d93c0cef53" + sha256: c4b5007d91ca4f67256e720cb1b6d704e79a510183a12fa551021f652577dce6 url: "https://pub.dev" source: hosted - version: "0.3.0" + version: "1.0.3" mutex: dependency: transitive description: @@ -474,22 +394,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.0" - node_preamble: - dependency: transitive - description: - name: node_preamble - sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" - url: "https://pub.dev" - source: hosted - version: "2.0.2" - package_config: - dependency: transitive - description: - name: package_config - sha256: "1c5b77ccc91e4823a5af61ee74e6b972db1ef98c2ff5a18d3161c982a55448bd" - url: "https://pub.dev" - source: hosted - version: "2.1.0" package_info_plus: dependency: transitive description: @@ -538,22 +442,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.7.3" - pool: - dependency: transitive - description: - name: pool - sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" - url: "https://pub.dev" - source: hosted - version: "1.5.1" - pub_semver: - dependency: transitive - description: - name: pub_semver - sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c" - url: "https://pub.dev" - source: hosted - version: "2.1.4" retry: dependency: "direct main" description: @@ -570,14 +458,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" - shelf_packages_handler: - dependency: transitive - description: - name: shelf_packages_handler - sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" - url: "https://pub.dev" - source: hosted - version: "3.0.2" shelf_router: dependency: transitive description: @@ -586,43 +466,11 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.4" - shelf_static: - dependency: transitive - description: - name: shelf_static - sha256: a41d3f53c4adf0f57480578c1d61d90342cd617de7fc8077b1304643c2d85c1e - url: "https://pub.dev" - source: hosted - version: "1.1.2" - shelf_web_socket: - dependency: transitive - description: - name: shelf_web_socket - sha256: "9ca081be41c60190ebcb4766b2486a7d50261db7bd0f5d9615f2d653637a84c1" - url: "https://pub.dev" - source: hosted - version: "1.0.4" sky_engine: dependency: transitive description: flutter source: sdk version: "0.0.99" - source_map_stack_trace: - dependency: transitive - description: - name: source_map_stack_trace - sha256: "84cf769ad83aa6bb61e0aa5a18e53aea683395f196a6f39c4c881fb90ed4f7ae" - url: "https://pub.dev" - source: hosted - version: "2.1.1" - source_maps: - dependency: transitive - description: - name: source_maps - sha256: "708b3f6b97248e5781f493b765c3337db11c5d2c81c3094f10904bfa8004c703" - url: "https://pub.dev" - source: hosted - version: "0.10.12" source_span: dependency: transitive description: @@ -663,14 +511,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.1" - test: - dependency: transitive - description: - name: test - sha256: a1f7595805820fcc05e5c52e3a231aedd0b72972cb333e8c738a8b1239448b6f - url: "https://pub.dev" - source: hosted - version: "1.24.9" test_api: dependency: transitive description: @@ -679,14 +519,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.1" - test_core: - dependency: transitive - description: - name: test_core - sha256: a757b14fc47507060a162cc2530d9a4a2f92f5100a952c7443b5cad5ef5b106a - url: "https://pub.dev" - source: hosted - version: "0.5.9" typed_data: dependency: transitive description: @@ -719,22 +551,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" - vm_service: - dependency: transitive - description: - name: vm_service - sha256: c538be99af830f478718b51630ec1b6bee5e74e52c8a802d328d9e71d35d2583 - url: "https://pub.dev" - source: hosted - version: "11.10.0" - watcher: - dependency: transitive - description: - name: watcher - sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8" - url: "https://pub.dev" - source: hosted - version: "1.1.0" web: dependency: transitive description: @@ -743,22 +559,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.3.0" - web_socket_channel: - dependency: transitive - description: - name: web_socket_channel - sha256: d88238e5eac9a42bb43ca4e721edba3c08c6354d4a53063afaa568516217621b - url: "https://pub.dev" - source: hosted - version: "2.4.0" - webkit_inspection_protocol: - dependency: transitive - description: - name: webkit_inspection_protocol - sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" - url: "https://pub.dev" - source: hosted - version: "1.2.1" win32: dependency: transitive description: @@ -775,14 +575,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" - yaml: - dependency: transitive - description: - name: yaml - sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" - url: "https://pub.dev" - source: hosted - version: "3.1.2" sdks: dart: ">=3.2.0-194.0.dev <4.0.0" flutter: ">=3.7.12" diff --git a/pubspec.lock b/pubspec.lock index 847565dc22..eac34e798d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -41,6 +41,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.8" + ansicolor: + dependency: transitive + description: + name: ansicolor + sha256: "8bf17a8ff6ea17499e40a2d2542c2f481cd7615760c6d34065cb22bfd22e6880" + url: "https://pub.dev" + source: hosted + version: "2.0.2" app_settings: dependency: "direct main" description: @@ -998,6 +1006,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + globbing: + dependency: transitive + description: + name: globbing + sha256: "4f89cfaf6fa74c9c1740a96259da06bd45411ede56744e28017cc534a12b6e2d" + url: "https://pub.dev" + source: hosted + version: "1.0.0" gql: dependency: transitive description: @@ -1182,6 +1198,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.1+1" + injector: + dependency: transitive + description: + name: injector + sha256: ed389bed5b48a699d5b9561c985023d0d5cc88dd5ff2237aadcce5a5ab433e4e + url: "https://pub.dev" + source: hosted + version: "3.0.0" integration_test: dependency: "direct dev" description: flutter @@ -1706,6 +1730,30 @@ packages: url: "https://pub.dev" source: hosted version: "0.0.1" + sentry: + dependency: transitive + description: + name: sentry + sha256: "89e426587b0879e53c46a0aae0eb312696d9d2d803ba14b252a65cc24b1416a2" + url: "https://pub.dev" + source: hosted + version: "7.14.0" + sentry_dart_plugin: + dependency: "direct dev" + description: + name: sentry_dart_plugin + sha256: fb46695c054b23ba631b16227c3e602ce31d1d5ccade9583d5184a7a74f7a2be + url: "https://pub.dev" + source: hosted + version: "1.6.3" + sentry_flutter: + dependency: "direct main" + description: + name: sentry_flutter + sha256: fd089ee4e75a927be037c56815a0a54af5a519f52b803a5ffecb589bb36e2401 + url: "https://pub.dev" + source: hosted + version: "7.14.0" share_plus: dependency: "direct main" description: @@ -2009,6 +2057,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.0" + system_info2: + dependency: transitive + description: + name: system_info2 + sha256: "65206bbef475217008b5827374767550a5420ce70a04d2d7e94d1d2253f3efc9" + url: "https://pub.dev" + source: hosted + version: "4.0.0" system_info_plus: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index dfe7d0ff22..4ee676db9c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: Secure, permanent storage publish_to: 'none' -version: 2.30.2 +version: 2.31.0 environment: sdk: '>=3.0.2 <4.0.0' @@ -132,6 +132,7 @@ dependencies: loading_animation_widget: ^1.2.0+4 synchronized: ^3.1.0 confetti: ^0.7.0 + sentry_flutter: ^7.14.0 dependency_overrides: stripe_js: @@ -175,6 +176,7 @@ dev_dependencies: drift_dev: mocktail: ^1.0.2 json_serializable: + sentry_dart_plugin: ^1.0.0 flutter: uses-material-design: true @@ -187,3 +189,11 @@ flutter: - assets/images/profile/ - assets/images/login/ - assets/animations/ + +sentry: + upload_debug_symbols: true + upload_source_maps: true + upload_sources: true + wait_for_processing: false + log_level: error + ignore_missing: true \ No newline at end of file diff --git a/test/blocs/drives_cubit_test.dart b/test/blocs/drives_cubit_test.dart index 7356852725..831dd814dc 100644 --- a/test/blocs/drives_cubit_test.dart +++ b/test/blocs/drives_cubit_test.dart @@ -1,6 +1,7 @@ @Tags(['broken']) import 'package:ardrive/blocs/blocs.dart'; +import 'package:ardrive/blocs/prompt_to_snapshot/prompt_to_snapshot_bloc.dart'; import 'package:ardrive/core/activity_tracker.dart'; import 'package:ardrive/models/models.dart'; import 'package:bloc_test/bloc_test.dart'; @@ -19,6 +20,7 @@ void main() { late ProfileCubit profileCubit; late DrivesCubit drivesCubit; + late PromptToSnapshotBloc promptToSnapshotBloc; setUp(() { registerFallbackValue(SyncStateFake()); @@ -27,12 +29,14 @@ void main() { driveDao = db.driveDao; profileCubit = MockProfileCubit(); + promptToSnapshotBloc = MockPromptToSnapshotBloc(); drivesCubit = DrivesCubit( activityTracker: MockActivityTracker(), auth: MockArDriveAuth(), profileCubit: profileCubit, driveDao: driveDao, + promptToSnapshotBloc: promptToSnapshotBloc, ); }); diff --git a/test/blocs/prompt_to_snapshot_bloc_test.dart b/test/blocs/prompt_to_snapshot_bloc_test.dart new file mode 100644 index 0000000000..60c4156266 --- /dev/null +++ b/test/blocs/prompt_to_snapshot_bloc_test.dart @@ -0,0 +1,249 @@ +import 'package:ardrive/blocs/profile/profile_cubit.dart'; +import 'package:ardrive/blocs/prompt_to_snapshot/prompt_to_snapshot_bloc.dart'; +import 'package:ardrive/blocs/prompt_to_snapshot/prompt_to_snapshot_event.dart'; +import 'package:ardrive/blocs/prompt_to_snapshot/prompt_to_snapshot_state.dart'; +import 'package:ardrive/models/daos/daos.dart'; +import 'package:ardrive/models/database/database.dart'; +import 'package:ardrive/user/repositories/user_repository.dart'; +import 'package:ardrive/utils/key_value_store.dart'; +import 'package:ardrive/utils/local_key_value_store.dart'; +import 'package:ardrive_utils/ardrive_utils.dart'; +import 'package:arweave/arweave.dart'; +import 'package:bloc_test/bloc_test.dart'; +import 'package:cryptography/cryptography.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../test_utils/utils.dart'; + +const durationBeforePrompting = Duration(milliseconds: 200); +const numberOfTxsBeforeSnapshot = 3; +const driveId = 'test-drive-id'; + +void main() { + late PromptToSnapshotBloc promptToSnapshotBloc; + late KeyValueStore store; + late UserRepository userRepository; + late ProfileCubit profileCubit; + late Database db; + late DriveDao driveDao; + late Wallet testWallet; + late String walletAddress; + late DriveID driveId; + + WidgetsFlutterBinding.ensureInitialized(); + + group('PromptToSnapshotBloc class', () { + setUpAll(() async { + SharedPreferences.setMockInitialValues({}); + final fakePrefs = await SharedPreferences.getInstance(); + store = await LocalKeyValueStore.getInstance(prefs: fakePrefs); + + userRepository = MockUserRepository(); + when(() => userRepository.getOwnerOfDefaultProfile()) + .thenAnswer((_) => Future.value('test-owner')); + + testWallet = getTestWallet(); + walletAddress = await testWallet.getAddress(); + + profileCubit = MockProfileCubit(); + when(() => profileCubit.state).thenReturn(ProfileLoggedIn( + username: 'test-username', + password: 'test-password', + wallet: testWallet, + walletAddress: walletAddress, + walletBalance: BigInt.one, + cipherKey: SecretKey(List.generate(32, (index) => index)), + useTurbo: false, + )); + + db = getTestDb(); + driveDao = db.driveDao; + + final drive = await driveDao.createDrive( + name: "Mati's drive", + ownerAddress: walletAddress, + privacy: 'public', + wallet: testWallet, + password: '123', + profileKey: SecretKey([1, 2, 3, 4, 5]), + ); + driveId = drive.driveId; + }); + + setUp(() { + promptToSnapshotBloc = PromptToSnapshotBloc( + store: store, + durationBeforePrompting: durationBeforePrompting, + numberOfTxsBeforeSnapshot: numberOfTxsBeforeSnapshot, + userRepository: userRepository, + profileCubit: profileCubit, + driveDao: driveDao, + ); + }); + + blocTest( + 'can count through txs', + build: () => promptToSnapshotBloc, + act: (PromptToSnapshotBloc bloc) async { + bloc.add(CountSyncedTxs( + driveId: driveId, + txsSyncedWithGqlCount: 1, + wasDeepSync: false, + )); + bloc.add(CountSyncedTxs( + driveId: driveId, + txsSyncedWithGqlCount: 2, + wasDeepSync: false, + )); + bloc.add(CountSyncedTxs( + driveId: driveId, + txsSyncedWithGqlCount: 3, + wasDeepSync: false, + )); + }, + expect: () => [ + // By this point we've counted 6 TXs + ], + ); + + blocTest( + 'will prompt to snapshot after enough txs', + build: () => promptToSnapshotBloc, + act: (PromptToSnapshotBloc bloc) async { + bloc.add(SelectedDrive(driveId: driveId)); + const durationAfterPrompting = Duration(milliseconds: 250); + await Future.delayed(durationAfterPrompting); + }, + expect: () => [ + PromptToSnapshotPrompting(driveId: driveId), + ], + ); + + blocTest( + 'will not prompt to snapshot if drive is deselected', + build: () => promptToSnapshotBloc, + act: (PromptToSnapshotBloc bloc) async { + bloc.add(SelectedDrive(driveId: driveId)); + + // This delay sumulates the user moving the cursor to a different drive. + /// Without this delay, the test will fail because the bloc will + /// first handle the event for nulling the driveId, and then handle + /// the event for selecting the driveId. + await Future.delayed(const Duration(milliseconds: 1)); + + bloc.add(const SelectedDrive(driveId: null)); + const durationAfterPrompting = Duration(milliseconds: 250); + await Future.delayed(durationAfterPrompting); + }, + expect: () => [ + const PromptToSnapshotIdle(), + ], + ); + + blocTest( + 'selecting an already snapshotted drive does nothing', + build: () => promptToSnapshotBloc, + act: (PromptToSnapshotBloc bloc) async { + bloc.add(DriveSnapshotted(driveId: driveId)); + bloc.add(SelectedDrive(driveId: driveId)); + const durationAfterPrompting = Duration(milliseconds: 250); + await Future.delayed(durationAfterPrompting); + }, + expect: () => [ + const PromptToSnapshotIdle(), + ], + ); + + blocTest( + 'selecting a drive after choosing not to be asked again does nothing', + build: () => promptToSnapshotBloc, + act: (PromptToSnapshotBloc bloc) async { + bloc.add(const DismissDontAskAgain(dontAskAgain: false)); + bloc.add(SelectedDrive(driveId: driveId)); + const durationAfterPrompting = Duration(milliseconds: 250); + await Future.delayed(durationAfterPrompting); + }, + expect: () => [ + const PromptToSnapshotIdle(), + ], + ); + + blocTest( + 'selecting a drive after choosing to be asked again does prompt', + build: () => promptToSnapshotBloc, + act: (PromptToSnapshotBloc bloc) async { + bloc.add(const DismissDontAskAgain(dontAskAgain: true)); + bloc.add(SelectedDrive(driveId: driveId)); + const durationAfterPrompting = Duration(milliseconds: 250); + await Future.delayed(durationAfterPrompting); + }, + expect: () => [ + const PromptToSnapshotIdle(), + ], + ); + + blocTest( + 'selecting a drive while not logged in does nothing', + build: () => promptToSnapshotBloc, + act: (PromptToSnapshotBloc bloc) async { + when(() => userRepository.getOwnerOfDefaultProfile()) + .thenAnswer((_) => Future.value(null)); + bloc.add(SelectedDrive(driveId: driveId)); + const durationAfterPrompting = Duration(milliseconds: 250); + await Future.delayed(durationAfterPrompting); + }, + expect: () => [], + ); + + blocTest( + 'selecting a drive while sync is running does nothing', + build: () => promptToSnapshotBloc, + act: (PromptToSnapshotBloc bloc) async { + bloc.add(SelectedDrive(driveId: driveId)); + const durationAfterPrompting = Duration(milliseconds: 250); + await Future.delayed(durationAfterPrompting); + }, + expect: () => [], + ); + + blocTest( + 'won\'t prompt to snapshot if drive is already snapshotting', + build: () => promptToSnapshotBloc, + act: (PromptToSnapshotBloc bloc) async { + bloc.add(SelectedDrive(driveId: driveId)); + await Future.delayed(const Duration(milliseconds: 1)); + bloc.add(DriveSnapshotting(driveId: driveId)); + const durationAfterPrompting = Duration(milliseconds: 250); + await Future.delayed(durationAfterPrompting); + }, + expect: () => [ + PromptToSnapshotSnapshotting(driveId: driveId), + ], + ); + + blocTest( + 'selecting a drive whith no write permissions does nothing', + build: () => promptToSnapshotBloc, + act: (PromptToSnapshotBloc bloc) async { + final aDifferentWallet = getTestWallet(); + final aDifferentWalletAddress = await aDifferentWallet.getAddress(); + when(() => profileCubit.state).thenReturn(ProfileLoggedIn( + username: 'another-test-username', + password: 'another-test-password', + wallet: aDifferentWallet, + walletAddress: aDifferentWalletAddress, + walletBalance: BigInt.one, + cipherKey: SecretKey(List.generate(32, (index) => index)), + useTurbo: false, + )); + bloc.add(SelectedDrive(driveId: driveId)); + const durationAfterPrompting = Duration(milliseconds: 250); + await Future.delayed(durationAfterPrompting); + }, + expect: () => [], + ); + }); +} diff --git a/test/entities/manifest_data_test.dart b/test/entities/manifest_data_test.dart index 7118c22795..a423c47427 100644 --- a/test/entities/manifest_data_test.dart +++ b/test/entities/manifest_data_test.dart @@ -157,8 +157,9 @@ void main() { group('fromFolderNode static method', () { test('returns a ManifestEntity with a valid expected manifest shape', () async { - final manifest = - ManifestData.fromFolderNode(folderNode: stubRootFolderNode); + final manifest = ManifestData.fromFolderNode( + folderNode: stubRootFolderNode, + ); expect( manifest.toJson(), @@ -192,8 +193,9 @@ void main() { test( 'returns a ManifestEntity with a valid expected manifest shape with a nested child folder', () async { - final manifest = - ManifestData.fromFolderNode(folderNode: stubChildFolderNode); + final manifest = ManifestData.fromFolderNode( + folderNode: stubChildFolderNode, + ); expect( manifest.toJson(), @@ -224,8 +226,9 @@ void main() { test('returns a DataItem with the expected tags, owner, and data', () async { - final manifest = - ManifestData.fromFolderNode(folderNode: stubRootFolderNode); + final manifest = ManifestData.fromFolderNode( + folderNode: stubRootFolderNode, + ); final wallet = getTestWallet(); AppPlatform.setMockPlatform(platform: SystemPlatform.Android); diff --git a/test/test_utils/mocks.dart b/test/test_utils/mocks.dart index dc8b1469da..5d18070bca 100644 --- a/test/test_utils/mocks.dart +++ b/test/test_utils/mocks.dart @@ -1,5 +1,6 @@ import 'package:ardrive/authentication/ardrive_auth.dart'; import 'package:ardrive/blocs/blocs.dart'; +import 'package:ardrive/blocs/prompt_to_snapshot/prompt_to_snapshot_bloc.dart'; import 'package:ardrive/blocs/upload/upload_file_checker.dart'; import 'package:ardrive/core/arfs/entities/arfs_entities.dart'; import 'package:ardrive/core/arfs/repository/arfs_repository.dart'; @@ -92,6 +93,8 @@ class MockTransactionCommonMixin extends Mock class MockDeviceInfoPlugin extends Mock implements DeviceInfoPlugin {} +class MockPromptToSnapshotBloc extends Mock implements PromptToSnapshotBloc {} + class MockARFSFile extends ARFSFileEntity { MockARFSFile({ required super.appName, diff --git a/test/utils/debouncer_test.dart b/test/utils/debouncer_test.dart new file mode 100644 index 0000000000..d806313510 --- /dev/null +++ b/test/utils/debouncer_test.dart @@ -0,0 +1,48 @@ +import 'package:ardrive/utils/debouncer.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('Debouncer class', () { + test('should run action after delay', () async { + final debouncer = Debouncer(delay: const Duration(milliseconds: 100)); + var run = false; + debouncer.run(() async { + run = true; + }); + expect(run, false); + await Future.delayed(const Duration(milliseconds: 200)); + expect(run, true); + }); + + test('should cancel action', () async { + final debouncer = Debouncer(delay: const Duration(milliseconds: 100)); + var run = false; + debouncer.run(() async { + run = true; + }).catchError((e) { + expect(e, 'Cancelled'); + }); + expect(run, false); + debouncer.cancel(); + await Future.delayed(const Duration(milliseconds: 200)); + expect(run, false); + }); + + test('should run only last action', () async { + final debouncer = Debouncer(delay: const Duration(milliseconds: 100)); + var run = false; + debouncer.run(() async { + run = true; + }).catchError((e) { + expect(e, 'Cancelled'); + }); + debouncer.run(() async { + run = false; + }).catchError((e) { + expect(e, 'Cancelled'); + }); + await Future.delayed(const Duration(milliseconds: 200)); + expect(run, false); + }); + }); +}