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/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/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..8d4a2016aa 100644 --- a/lib/components/new_button/new_button.dart +++ b/lib/components/new_button/new_button.dart @@ -349,7 +349,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/main.dart b/lib/main.dart index e0bd246033..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'; @@ -177,7 +178,7 @@ class AppState extends State { context.read().add(ChangeTheme()); }, key: arDriveAppKey, - builder: (context) => app, + builder: _appBuilder, ); }, ), @@ -187,33 +188,35 @@ class AppState extends State { ); } - MaterialApp get app => 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!, - ), + 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'; - ThemeData get _ardriveTheme => - ArDriveTheme.of(context).themeData.materialThemeData.copyWith( - scaffoldBackgroundColor: - ArDriveTheme.of(context).themeData.backgroundColor, - ); - Iterable get _locales => const [ Locale('en', ''), // English, no country code Locale('es', ''), // Spanish, no country code @@ -257,6 +260,13 @@ class AppState extends State { create: (context) => FeedbackSurveyCubit(FeedbackSurveyInitialState()), ), + BlocProvider( + create: (context) => PromptToSnapshotBloc( + userRepository: context.read(), + profileCubit: context.read(), + driveDao: context.read(), + ), + ), ]; List get repositoryProviders => [ 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/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/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/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); + }); + }); +}