diff --git a/lib/providers/folder_provider.dart b/lib/providers/folder_provider.dart index 1de2c408..78c82b5f 100644 --- a/lib/providers/folder_provider.dart +++ b/lib/providers/folder_provider.dart @@ -12,6 +12,7 @@ import 'package:icarus/providers/collab/remote_library_provider.dart'; import 'package:icarus/providers/library_workspace_provider.dart'; import 'package:icarus/providers/pinned_items_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/services/cloud_library_action.dart'; import 'package:icarus/strategy/strategy_models.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; import 'package:uuid/uuid.dart'; @@ -144,26 +145,32 @@ class FolderProvider extends Notifier { .firstOrNull; } - Future deleteFolder( + Future deleteFolder( String folderID, { LibraryWorkspace? workspace, }) async { final targetWorkspace = workspace ?? _currentWorkspace; if (targetWorkspace == LibraryWorkspace.cloud) { - try { - await ref.read(convexStrategyRepositoryProvider).deleteFolder(folderID); - } catch (error, stackTrace) { - await _maybeReportCloudUnauthenticated( - source: 'folder:delete', - error: error, - stackTrace: stackTrace, - ); - return; - } + final result = await _runCloudAction( + action: () async { + await ref + .read(convexStrategyRepositoryProvider) + .deleteFolder(folderID); + return true; + }, + source: 'folder:delete', + failureMessage: "Couldn't delete this cloud folder. Try again.", + ); + if (!result.didSucceed) return result; + + await ref.read(pinnedItemsProvider.notifier).removePin(folderID); if (_currentFolderIdForWorkspace(LibraryWorkspace.cloud) == folderID) { updateWorkspaceFolderId(LibraryWorkspace.cloud, null); } - return; + ref.invalidate(cloudFoldersProvider); + ref.invalidate(cloudAllFoldersProvider); + ref.invalidate(cloudStrategiesProvider); + return result; } await ref.read(pinnedItemsProvider.notifier).removePin(folderID); @@ -186,9 +193,10 @@ class FolderProvider extends Notifier { } await Hive.box(HiveBoxNames.foldersBox).delete(folderID); + return CloudLibraryActionResult.succeeded; } - void editFolder({ + Future editFolder({ required Folder folder, required String newName, required int newIconId, @@ -201,30 +209,31 @@ class FolderProvider extends Notifier { final newIcon = FolderIconRegistry.resolve(newIconId).iconData; final iconFontFamily = newIcon?.fontFamily; final iconFontPackage = newIcon?.fontPackage; - try { - await ref.read(convexStrategyRepositoryProvider).updateFolder( - folderPublicId: folder.id, - name: newName, - iconId: newIconId, - iconCodePoint: newIcon?.codePoint, - iconFontFamily: iconFontFamily, - clearIconFontFamily: iconFontFamily == null, - iconFontPackage: iconFontPackage, - clearIconFontPackage: iconFontPackage == null, - color: newColor.name, - customColorValue: newCustomColor?.toARGB32(), - clearCustomColorValue: newCustomColor == null, - ); - ref.invalidate(cloudFoldersProvider); - ref.invalidate(cloudAllFoldersProvider); - } catch (error, stackTrace) { - await _maybeReportCloudUnauthenticated( - source: 'folder:update', - error: error, - stackTrace: stackTrace, - ); - } - return; + final result = await _runCloudAction( + action: () async { + await ref.read(convexStrategyRepositoryProvider).updateFolder( + folderPublicId: folder.id, + name: newName, + iconId: newIconId, + iconCodePoint: newIcon?.codePoint, + iconFontFamily: iconFontFamily, + clearIconFontFamily: iconFontFamily == null, + iconFontPackage: iconFontPackage, + clearIconFontPackage: iconFontPackage == null, + color: newColor.name, + customColorValue: newCustomColor?.toARGB32(), + clearCustomColorValue: newCustomColor == null, + ); + return true; + }, + source: 'folder:update', + failureMessage: "Couldn't update this cloud folder. Try again.", + ); + if (!result.didSucceed) return result; + + ref.invalidate(cloudFoldersProvider); + ref.invalidate(cloudAllFoldersProvider); + return result; } folder.name = newName; @@ -232,28 +241,33 @@ class FolderProvider extends Notifier { folder.customColor = newCustomColor; folder.color = newColor; await folder.save(); + return CloudLibraryActionResult.succeeded; } - void moveToFolder({ + Future moveToFolder({ required String folderID, String? parentID, LibraryWorkspace? workspace, }) async { final targetWorkspace = workspace ?? _currentWorkspace; if (targetWorkspace == LibraryWorkspace.cloud) { - try { - await ref.read(convexStrategyRepositoryProvider).moveFolder( - folderPublicId: folderID, - parentFolderPublicId: parentID, - ); - } catch (error, stackTrace) { - await _maybeReportCloudUnauthenticated( - source: 'folder:move', - error: error, - stackTrace: stackTrace, - ); - } - return; + final result = await _runCloudAction( + action: () async { + await ref.read(convexStrategyRepositoryProvider).moveFolder( + folderPublicId: folderID, + parentFolderPublicId: parentID, + ); + return true; + }, + source: 'folder:move', + failureMessage: "Couldn't move this cloud folder. Try again.", + showFailureMessage: true, + ); + if (!result.didSucceed) return result; + + ref.invalidate(cloudFoldersProvider); + ref.invalidate(cloudAllFoldersProvider); + return result; } final folder = findLocalFolderByID(folderID); @@ -262,6 +276,28 @@ class FolderProvider extends Notifier { folder.parentID = parentID; await folder.save(); } + return CloudLibraryActionResult.succeeded; + } + + Future _runCloudAction({ + required Future Function() action, + required String source, + required String failureMessage, + bool showFailureMessage = false, + }) { + return ref.read(cloudLibraryActionReporterProvider).run( + action: action, + source: source, + failureMessage: failureMessage, + showFailureMessage: showFailureMessage, + reportAuthenticationFailure: (error, stackTrace) => ref + .read(authProvider.notifier) + .reportConvexUnauthenticated( + source: source, + error: error, + stackTrace: stackTrace, + ), + ); } LibraryWorkspace get _currentWorkspace => ref.read(libraryWorkspaceProvider); diff --git a/lib/providers/strategy_provider.dart b/lib/providers/strategy_provider.dart index 4723c9c7..9582cd8e 100644 --- a/lib/providers/strategy_provider.dart +++ b/lib/providers/strategy_provider.dart @@ -42,6 +42,7 @@ import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; import 'package:icarus/providers/strategy_page_session_provider.dart'; import 'package:icarus/providers/strategy_save_state_provider.dart'; import 'package:icarus/services/analytics_service.dart'; +import 'package:icarus/services/cloud_library_action.dart'; import 'package:icarus/strategy/strategy_migrator.dart'; import 'package:icarus/strategy/strategy_models.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; @@ -1423,42 +1424,55 @@ class StrategyProvider extends Notifier { await strategyBox.put(duplicatedStrategy.id, duplicatedStrategy); } - Future deleteStrategy( + Future deleteStrategy( String strategyID, { StrategySource? source, }) async { - await ref.read(pinnedItemsProvider.notifier).removePin(strategyID); final resolvedSource = source ?? _resolveLibraryMutationSource(); if (resolvedSource == StrategySource.cloud) { - try { - final shell = await ref - .read(convexStrategyRepositoryProvider) - .fetchShell(strategyID); - await ref.read(convexStrategyRepositoryProvider).deleteStrategy( - strategyPublicId: strategyID, - expectedRevision: shell.header.revision, - ); - } catch (error, stackTrace) { - final handled = await _reportCloudUnauthenticated( - source: 'strategy:delete', - error: error, - stackTrace: stackTrace, - ); - if (!handled) rethrow; - } + const sourceName = 'strategy:delete'; + final result = await ref.read(cloudLibraryActionReporterProvider).run( + action: () async { + final shell = await ref + .read(convexStrategyRepositoryProvider) + .fetchShell(strategyID); + await ref.read(convexStrategyRepositoryProvider).deleteStrategy( + strategyPublicId: strategyID, + expectedRevision: shell.header.revision, + ); + return true; + }, + source: sourceName, + failureMessage: + "Couldn't delete this cloud strategy. Try again.", + reportAuthenticationFailure: (error, stackTrace) => ref + .read(authProvider.notifier) + .reportConvexUnauthenticated( + source: sourceName, + error: error, + stackTrace: stackTrace, + ), + ); + if (!result.didSucceed) return result; + + await ref.read(pinnedItemsProvider.notifier).removePin(strategyID); ref.invalidate(cloudStrategiesProvider); - return; + return result; } + await ref.read(pinnedItemsProvider.notifier).removePin(strategyID); await Hive.box(HiveBoxNames.strategiesBox).delete(strategyID); final directory = await getApplicationSupportDirectory(); final customDirectory = Directory(path.join(directory.path, strategyID)); - if (!await customDirectory.exists()) return; + if (!await customDirectory.exists()) { + return CloudLibraryActionResult.succeeded; + } await customDirectory.delete(recursive: true); + return CloudLibraryActionResult.succeeded; } Future saveToHive(String id) async { @@ -1656,42 +1670,54 @@ class StrategyProvider extends Notifier { return StrategySettings(); } - void moveToFolder({ + Future moveToFolder({ required String strategyID, required String? parentID, StrategySource? source, - }) { + }) async { final resolvedSource = source ?? _resolveLibraryMutationSource(); if (resolvedSource == StrategySource.cloud) { - unawaited(() async { - try { - final shell = await ref - .read(convexStrategyRepositoryProvider) - .fetchShell(strategyID); - await ref.read(convexStrategyRepositoryProvider).moveStrategy( - strategyPublicId: strategyID, - folderPublicId: parentID, - expectedRevision: shell.header.revision, - ); - } catch (error, stackTrace) { - await _reportCloudUnauthenticated( - source: 'strategy:move', - error: error, - stackTrace: stackTrace, + const sourceName = 'strategy:move'; + final result = await ref.read(cloudLibraryActionReporterProvider).run( + action: () async { + final shell = await ref + .read(convexStrategyRepositoryProvider) + .fetchShell(strategyID); + await ref.read(convexStrategyRepositoryProvider).moveStrategy( + strategyPublicId: strategyID, + folderPublicId: parentID, + expectedRevision: shell.header.revision, + ); + return true; + }, + source: sourceName, + failureMessage: "Couldn't move this cloud strategy. Try again.", + showFailureMessage: true, + reportAuthenticationFailure: (error, stackTrace) => ref + .read(authProvider.notifier) + .reportConvexUnauthenticated( + source: sourceName, + error: error, + stackTrace: stackTrace, + ), ); - } - }()); + if (!result.didSucceed) return result; + ref.invalidate(cloudStrategiesProvider); - return; + return result; } final strategyBox = Hive.box(HiveBoxNames.strategiesBox); final strategy = strategyBox.get(strategyID); if (strategy != null) { strategy.folderID = parentID; - strategy.save(); + await strategy.save(); + return CloudLibraryActionResult.succeeded; } else { log("Strategy with ID $strategyID not found."); + return CloudLibraryActionResult.failed( + "Couldn't move this strategy. Try again.", + ); } } } diff --git a/lib/services/cloud_library_action.dart b/lib/services/cloud_library_action.dart new file mode 100644 index 00000000..fa0d75be --- /dev/null +++ b/lib/services/cloud_library_action.dart @@ -0,0 +1,107 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/services/app_error_reporter.dart'; + +enum CloudLibraryActionStatus { + succeeded, + cancelled, + authenticationRequired, + failed, +} + +class CloudLibraryActionResult { + const CloudLibraryActionResult._(this.status, {this.userMessage}); + + static const succeeded = CloudLibraryActionResult._( + CloudLibraryActionStatus.succeeded, + ); + static const cancelled = CloudLibraryActionResult._( + CloudLibraryActionStatus.cancelled, + ); + static const authenticationRequired = CloudLibraryActionResult._( + CloudLibraryActionStatus.authenticationRequired, + ); + + factory CloudLibraryActionResult.failed(String userMessage) => + CloudLibraryActionResult._( + CloudLibraryActionStatus.failed, + userMessage: userMessage, + ); + + final CloudLibraryActionStatus status; + final String? userMessage; + + bool get didSucceed => status == CloudLibraryActionStatus.succeeded; + bool get wasCancelled => status == CloudLibraryActionStatus.cancelled; +} + +final cloudLibraryActionReporterProvider = Provider( + (_) => const CloudLibraryActionReporter(), +); + +class CloudLibraryActionReporter { + const CloudLibraryActionReporter({ + this.showMessage = _showDefaultMessage, + this.reportTechnicalFailure = _reportDefaultTechnicalFailure, + }); + + final void Function(String message) showMessage; + final void Function({ + required String source, + required Object error, + required StackTrace stackTrace, + }) reportTechnicalFailure; + + Future run({ + required Future Function() action, + required String source, + required String failureMessage, + required Future Function(Object error, StackTrace stackTrace) + reportAuthenticationFailure, + bool showFailureMessage = false, + }) async { + try { + final completed = await action(); + return completed + ? CloudLibraryActionResult.succeeded + : CloudLibraryActionResult.cancelled; + } catch (error, stackTrace) { + if (isConvexUnauthenticatedError(error)) { + await reportAuthenticationFailure(error, stackTrace); + return CloudLibraryActionResult.authenticationRequired; + } + + reportTechnicalFailure( + source: source, + error: error, + stackTrace: stackTrace, + ); + if (showFailureMessage) { + showMessage(failureMessage); + } + return CloudLibraryActionResult.failed(failureMessage); + } + } + + static void _showDefaultMessage(String message) { + Settings.showToast( + message: message, + backgroundColor: Settings.tacticalVioletTheme.destructive, + ); + } + + static void _reportDefaultTechnicalFailure({ + required String source, + required Object error, + required StackTrace stackTrace, + }) { + AppErrorReporter.reportError( + 'Cloud library action failed.', + source: source, + error: error, + stackTrace: stackTrace, + promptUser: false, + ); + } +} diff --git a/lib/services/cloud_strategy_export.dart b/lib/services/cloud_strategy_export.dart new file mode 100644 index 00000000..24dcd745 --- /dev/null +++ b/lib/services/cloud_strategy_export.dart @@ -0,0 +1,23 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/services/cloud_library_action.dart'; +import 'package:icarus/strategy/strategy_import_export.dart'; + +Future runCloudStrategyExport( + WidgetRef ref, + String strategyId, +) { + const source = 'strategy:export'; + return ref.read(cloudLibraryActionReporterProvider).run( + action: () => ref.read(cloudStrategyExporterProvider)(strategyId), + source: source, + failureMessage: "Couldn't export this cloud strategy. Try again.", + showFailureMessage: true, + reportAuthenticationFailure: (error, stackTrace) => + ref.read(authProvider.notifier).reportConvexUnauthenticated( + source: source, + error: error, + stackTrace: stackTrace, + ), + ); +} diff --git a/lib/strategy/strategy_import_export.dart b/lib/strategy/strategy_import_export.dart index 8f8b1c99..8b29f31e 100644 --- a/lib/strategy/strategy_import_export.dart +++ b/lib/strategy/strategy_import_export.dart @@ -7,6 +7,7 @@ import 'package:cross_file/cross_file.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/foundation.dart' show kIsWeb, visibleForTesting; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter/services.dart'; import 'package:hive_ce/hive.dart'; import 'package:icarus/collab/collab_models.dart'; @@ -52,6 +53,12 @@ String buildLibraryBackupFileName(DateTime timestamp) { '${twoDigit(timestamp.hour)}-${twoDigit(timestamp.minute)}-${twoDigit(timestamp.second)}.zip'; } +typedef CloudStrategyExporter = Future Function(String strategyId); + +final cloudStrategyExporterProvider = Provider( + (ref) => StrategyImportExportService(ref).exportCloudStrategy, +); + class NewerVersionImportException implements Exception { const NewerVersionImportException({ required this.importedVersion, @@ -2426,7 +2433,7 @@ class StrategyImportExportService { } } - Future exportCloudStrategy(String strategyId) async { + Future exportCloudStrategy(String strategyId) async { final snapshot = await ref .read(convexStrategyRepositoryProvider) .fetchFullSnapshot(strategyId); @@ -2438,8 +2445,9 @@ class StrategyImportExportService { fileName: '${sanitizeStrategyFileName(strategy.name)}.ica', allowedExtensions: ['ica'], ); - if (outputFile == null) return; + if (outputFile == null) return false; await zipStrategyData(strategy: strategy, outputFilePath: outputFile); + return true; } Future exportFile(String id) async { diff --git a/lib/widgets/current_path_bar.dart b/lib/widgets/current_path_bar.dart index 2b73f039..36dd83a4 100644 --- a/lib/widgets/current_path_bar.dart +++ b/lib/widgets/current_path_bar.dart @@ -110,10 +110,10 @@ class FolderTab extends ConsumerWidget { child: Text(displayName), ); }, - onAcceptWithDetails: (details) { + onAcceptWithDetails: (details) async { final item = details.data; if (item is StrategyItem) { - ref.read(strategyProvider.notifier).moveToFolder( + await ref.read(strategyProvider.notifier).moveToFolder( strategyID: item.strategyId, parentID: folder?.id, source: item.strategy == null @@ -121,7 +121,7 @@ class FolderTab extends ConsumerWidget { : StrategySource.local, ); } else if (item is FolderItem) { - ref.read(folderProvider.notifier).moveToFolder( + await ref.read(folderProvider.notifier).moveToFolder( folderID: item.folder.id, parentID: folder?.id, workspace: ref.read(libraryWorkspaceProvider), diff --git a/lib/widgets/dialogs/delete_folder_alert_dialog.dart b/lib/widgets/dialogs/delete_folder_alert_dialog.dart new file mode 100644 index 00000000..5265e57a --- /dev/null +++ b/lib/widgets/dialogs/delete_folder_alert_dialog.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:icarus/providers/folder_provider.dart'; +import 'package:icarus/providers/library_workspace_provider.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +class DeleteFolderAlertDialog extends ConsumerStatefulWidget { + const DeleteFolderAlertDialog({ + super.key, + required this.folder, + required this.workspace, + }); + + final Folder folder; + final LibraryWorkspace workspace; + + @override + ConsumerState createState() => + _DeleteFolderAlertDialogState(); +} + +class _DeleteFolderAlertDialogState + extends ConsumerState { + bool _isDeleting = false; + String? _failureMessage; + + Future _delete() async { + if (_isDeleting) return; + setState(() { + _isDeleting = true; + _failureMessage = null; + }); + + final result = await ref.read(folderProvider.notifier).deleteFolder( + widget.folder.id, + workspace: widget.workspace, + ); + if (!mounted) return; + if (result.didSucceed) { + Navigator.of(context).pop(); + return; + } + + setState(() { + _isDeleting = false; + _failureMessage = result.userMessage; + }); + } + + @override + Widget build(BuildContext context) { + return ShadDialog.alert( + title: Text("Delete '${widget.folder.name}'?"), + description: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'This also removes every strategy and subfolder inside it.', + ), + if (_failureMessage != null) ...[ + const SizedBox(height: 10), + Text( + _failureMessage!, + key: const ValueKey('delete-folder-failure'), + style: TextStyle( + color: Settings.tacticalVioletTheme.destructive, + ), + ), + ], + ], + ), + actions: [ + ShadButton.secondary( + onPressed: _isDeleting ? null : () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + ShadButton.destructive( + key: const ValueKey('delete-folder-confirm'), + onPressed: _isDeleting ? null : _delete, + child: Text(_isDeleting ? 'Deleting...' : 'Delete'), + ), + ], + ); + } +} diff --git a/lib/widgets/dialogs/strategy/delete_strategy_alert_dialog.dart b/lib/widgets/dialogs/strategy/delete_strategy_alert_dialog.dart index b5666c3c..86e1f166 100644 --- a/lib/widgets/dialogs/strategy/delete_strategy_alert_dialog.dart +++ b/lib/widgets/dialogs/strategy/delete_strategy_alert_dialog.dart @@ -5,7 +5,7 @@ import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; -class DeleteStrategyAlertDialog extends ConsumerWidget { +class DeleteStrategyAlertDialog extends ConsumerStatefulWidget { const DeleteStrategyAlertDialog({ super.key, required this.strategyID, @@ -15,54 +15,92 @@ class DeleteStrategyAlertDialog extends ConsumerWidget { final String strategyID; final String name; final StrategySource source; + + @override + ConsumerState createState() => + _DeleteStrategyAlertDialogState(); +} + +class _DeleteStrategyAlertDialogState + extends ConsumerState { + bool _isDeleting = false; + String? _failureMessage; + + Future _delete() async { + if (_isDeleting) return; + setState(() { + _isDeleting = true; + _failureMessage = null; + }); + + final result = await ref.read(strategyProvider.notifier).deleteStrategy( + widget.strategyID, + source: widget.source, + ); + if (!mounted) return; + if (result.didSucceed) { + Navigator.of(context).pop(); + return; + } + + setState(() { + _isDeleting = false; + _failureMessage = result.userMessage; + }); + } + @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { return ShadDialog.alert( title: Text.rich( TextSpan( children: [ const TextSpan(text: "Delete "), TextSpan( - text: name, + text: widget.name, style: const TextStyle(fontWeight: FontWeight.bold), ), ], ), ), - description: Text.rich( - TextSpan( - children: [ - const TextSpan(text: "Are you sure you want to delete "), + description: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text.rich( TextSpan( - text: name, - style: const TextStyle(fontWeight: FontWeight.bold), + children: [ + const TextSpan(text: "Are you sure you want to delete "), + TextSpan( + text: widget.name, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + const TextSpan(text: "? This action cannot be undone."), + ], + ), + ), + if (_failureMessage != null) ...[ + const SizedBox(height: 10), + Text( + _failureMessage!, + key: const ValueKey('delete-strategy-failure'), + style: TextStyle( + color: Settings.tacticalVioletTheme.destructive, + ), ), - const TextSpan(text: "? This action cannot be undone."), ], - ), + ], ), actions: [ ShadButton.secondary( - onPressed: () { - Navigator.of(context).pop(); - }, + onPressed: _isDeleting ? null : () => Navigator.of(context).pop(), child: const Text( "Cancel", ), ), ShadButton.destructive( - // padding: WidgetStateProperty.all( - // const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - - onPressed: () async { - await ref - .read(strategyProvider.notifier) - .deleteStrategy(strategyID, source: source); - - if (!context.mounted) return; - - Navigator.of(context).pop(); - }, + key: const ValueKey('delete-strategy-confirm'), + onPressed: _isDeleting ? null : _delete, child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -72,7 +110,7 @@ class DeleteStrategyAlertDialog extends ConsumerWidget { ), const SizedBox(width: 5), Text( - "Delete", + _isDeleting ? 'Deleting...' : 'Delete', style: TextStyle( color: Settings.tacticalVioletTheme.destructiveForeground, ), diff --git a/lib/widgets/folder_card.dart b/lib/widgets/folder_card.dart index b64ed0b0..0d0f3da2 100644 --- a/lib/widgets/folder_card.dart +++ b/lib/widgets/folder_card.dart @@ -6,10 +6,11 @@ import 'package:icarus/const/maps.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/providers/folder_provider.dart'; import 'package:icarus/providers/library_context_menu_provider.dart'; +import 'package:icarus/providers/library_workspace_provider.dart'; import 'package:icarus/providers/pinned_items_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/strategy/strategy_import_export.dart'; -import 'package:icarus/widgets/dialogs/confirm_alert_dialog.dart'; +import 'package:icarus/widgets/dialogs/delete_folder_alert_dialog.dart'; import 'package:icarus/widgets/drag_tilt_feedback.dart'; import 'package:icarus/widgets/drop_insertion_indicator.dart'; import 'package:icarus/widgets/folder_edit_dialog.dart'; @@ -282,11 +283,11 @@ class _FolderCardState extends ConsumerState } if (item is StrategyItem) { - ref + await ref .read(strategyProvider.notifier) .moveToFolder(strategyID: item.strategy!.id, parentID: _folder.id); } else if (item is FolderItem) { - ref + await ref .read(folderProvider.notifier) .moveToFolder(folderID: item.folder.id, parentID: _folder.id); } @@ -673,19 +674,14 @@ class _FolderCardState extends ConsumerState child: const Text('Delete', style: TextStyle(color: Colors.redAccent)), onPressed: () async { _closeMenus(); - ConfirmAlertDialog.show( + if (widget.isDemo) return; + await showShadDialog( context: context, - title: "Are you sure you want to delete '${_folder.name}' folder?", - content: - "This will also delete all strategies and subfolders within it.", - confirmText: "Delete", - isDestructive: true, - ).then((confirmed) { - if (confirmed) { - if (widget.isDemo) return; - ref.read(folderProvider.notifier).deleteFolder(_folder.id); - } - }); + builder: (_) => DeleteFolderAlertDialog( + folder: _folder, + workspace: ref.read(libraryWorkspaceProvider), + ), + ); }, ), ]; diff --git a/lib/widgets/folder_edit_dialog.dart b/lib/widgets/folder_edit_dialog.dart index 6753449e..fa51171d 100644 --- a/lib/widgets/folder_edit_dialog.dart +++ b/lib/widgets/folder_edit_dialog.dart @@ -37,6 +37,53 @@ class _FolderEditDialogState extends ConsumerState { FolderColor _selectedColor = FolderColor.red; Color? _customColor; _FolderIconFilter _iconFilter = _FolderIconFilter.all; + bool _isSubmitting = false; + String? _failureMessage; + + Future _submit() async { + if (_isSubmitting) return; + setState(() { + _isSubmitting = true; + _failureMessage = null; + }); + + final name = _folderNameController.text.isEmpty + ? 'New Folder' + : _folderNameController.text; + if (widget.folder != null) { + final result = await ref.read(folderProvider.notifier).editFolder( + folder: widget.folder!, + newName: name, + newIconId: _selectedIconId, + newColor: _selectedColor, + newCustomColor: _customColor, + ); + if (!mounted) return; + if (result.didSucceed) { + Navigator.of(context).pop(); + return; + } + setState(() { + _isSubmitting = false; + _failureMessage = result.userMessage; + }); + return; + } + + try { + await ref.read(folderProvider.notifier).createFolder( + name: name, + iconId: _selectedIconId, + color: _selectedColor, + customColor: _customColor, + ); + } catch (_) { + if (mounted) setState(() => _isSubmitting = false); + return; + } + if (mounted) Navigator.of(context).pop(); + } + @override void dispose() { _folderNameController.dispose(); @@ -74,47 +121,28 @@ class _FolderEditDialogState extends ConsumerState { Padding( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), child: ShadButton( - leading: const Icon(Icons.check), - onPressed: () async { - if (widget.folder != null) { - ref.read(folderProvider.notifier).editFolder( - folder: widget.folder!, - newName: _folderNameController.text.isEmpty - ? "New Folder" - : _folderNameController.text, - newIconId: _selectedIconId, - newColor: _selectedColor, - newCustomColor: _customColor, - ); - if (context.mounted) Navigator.of(context).pop(); - return; - } - try { - await ref.read(folderProvider.notifier).createFolder( - name: _folderNameController.text.isEmpty - ? "New Folder" - : _folderNameController.text, - iconId: _selectedIconId, - color: _selectedColor, - customColor: _customColor, - ); - } catch (_) { - // Cloud folder creation failed (already toasted by the - // provider) — keep the dialog open so the user can retry. - return; - } - - if (context.mounted) Navigator.of(context).pop(); - }, - child: const Text("Done"), + key: const ValueKey('folder-edit-submit'), + leading: _isSubmitting ? null : const Icon(Icons.check), + onPressed: _isSubmitting ? null : _submit, + child: Text(_isSubmitting ? 'Saving...' : 'Done'), ), - ) + ), ], child: SizedBox( width: 358, child: Column( mainAxisSize: MainAxisSize.min, children: [ + if (_failureMessage != null) ...[ + Text( + _failureMessage!, + key: const ValueKey('folder-edit-failure'), + style: TextStyle( + color: Settings.tacticalVioletTheme.destructive, + ), + ), + const SizedBox(height: 10), + ], Container( height: 220, width: 358, diff --git a/lib/widgets/folder_navigator_sidebar.dart b/lib/widgets/folder_navigator_sidebar.dart index e72c2cbc..545e54e4 100644 --- a/lib/widgets/folder_navigator_sidebar.dart +++ b/lib/widgets/folder_navigator_sidebar.dart @@ -12,7 +12,7 @@ import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/strategy/strategy_import_export.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; import 'package:icarus/widgets/custom_search_field.dart'; -import 'package:icarus/widgets/dialogs/confirm_alert_dialog.dart'; +import 'package:icarus/widgets/dialogs/delete_folder_alert_dialog.dart'; import 'package:icarus/widgets/dialogs/share_links_dialog.dart'; import 'package:icarus/widgets/folder_edit_dialog.dart'; import 'package:icarus/widgets/folder_navigator.dart'; @@ -362,10 +362,10 @@ class _SidebarRootItem extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { return DragTarget( - onAcceptWithDetails: (details) { + onAcceptWithDetails: (details) async { final item = details.data; if (item is StrategyItem) { - ref.read(strategyProvider.notifier).moveToFolder( + await ref.read(strategyProvider.notifier).moveToFolder( strategyID: item.strategyId, parentID: null, source: item.strategy == null @@ -373,7 +373,7 @@ class _SidebarRootItem extends ConsumerWidget { : StrategySource.local, ); } else if (item is FolderItem) { - ref.read(folderProvider.notifier).moveToFolder( + await ref.read(folderProvider.notifier).moveToFolder( folderID: item.folder.id, parentID: null, workspace: ref.read(libraryWorkspaceProvider), @@ -545,10 +545,10 @@ class _FolderSidebarItemState extends ConsumerState<_FolderSidebarItem> { } return true; }, - onAcceptWithDetails: (details) { + onAcceptWithDetails: (details) async { final item = details.data; if (item is StrategyItem) { - ref.read(strategyProvider.notifier).moveToFolder( + await ref.read(strategyProvider.notifier).moveToFolder( strategyID: item.strategyId, parentID: folder.id, source: item.strategy == null @@ -556,7 +556,7 @@ class _FolderSidebarItemState extends ConsumerState<_FolderSidebarItem> { : StrategySource.local, ); } else if (item is FolderItem) { - ref.read(folderProvider.notifier).moveToFolder( + await ref.read(folderProvider.notifier).moveToFolder( folderID: item.folder.id, parentID: folder.id, workspace: ref.read(libraryWorkspaceProvider), @@ -708,21 +708,13 @@ class _FolderSidebarItemState extends ConsumerState<_FolderSidebarItem> { onPressed: !canManage ? null : () async { - final confirmed = await ConfirmAlertDialog.show( + await showShadDialog( context: context, - title: "Delete '${folder.name}'?", - content: - 'This also removes every strategy and subfolder inside it.', - confirmText: 'Delete', - isDestructive: true, + builder: (_) => DeleteFolderAlertDialog( + folder: folder, + workspace: ref.read(libraryWorkspaceProvider), + ), ); - if (!confirmed) { - return; - } - ref.read(folderProvider.notifier).deleteFolder( - folder.id, - workspace: ref.read(libraryWorkspaceProvider), - ); }, child: Text( 'Delete', diff --git a/lib/widgets/folder_pill.dart b/lib/widgets/folder_pill.dart index 17d9c9e0..4c883731 100644 --- a/lib/widgets/folder_pill.dart +++ b/lib/widgets/folder_pill.dart @@ -9,7 +9,7 @@ import 'package:icarus/providers/pinned_items_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/strategy/strategy_import_export.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; -import 'package:icarus/widgets/dialogs/confirm_alert_dialog.dart'; +import 'package:icarus/widgets/dialogs/delete_folder_alert_dialog.dart'; import 'package:icarus/widgets/dialogs/share_links_dialog.dart'; import 'package:icarus/widgets/drag_tilt_feedback.dart'; import 'package:icarus/widgets/drop_insertion_indicator.dart'; @@ -202,7 +202,7 @@ class _FolderPillState extends ConsumerState } if (item is StrategyItem) { - ref.read(strategyProvider.notifier).moveToFolder( + await ref.read(strategyProvider.notifier).moveToFolder( strategyID: item.strategyId, parentID: widget.folder.id, source: item.strategy == null @@ -210,7 +210,7 @@ class _FolderPillState extends ConsumerState : StrategySource.local, ); } else if (item is FolderItem) { - ref.read(folderProvider.notifier).moveToFolder( + await ref.read(folderProvider.notifier).moveToFolder( folderID: item.folder.id, parentID: widget.folder.id, workspace: ref.read(libraryWorkspaceProvider), @@ -442,23 +442,14 @@ class _FolderPillState extends ConsumerState ? null : () async { _closeMenus(); - ConfirmAlertDialog.show( + if (widget.isDemo) return; + await showShadDialog( context: context, - title: - "Are you sure you want to delete '${widget.folder.name}' folder?", - content: - "This will also delete all strategies and subfolders within it.", - confirmText: "Delete", - isDestructive: true, - ).then((confirmed) { - if (confirmed) { - if (widget.isDemo) return; - ref.read(folderProvider.notifier).deleteFolder( - widget.folder.id, - workspace: ref.read(libraryWorkspaceProvider), - ); - } - }); + builder: (_) => DeleteFolderAlertDialog( + folder: widget.folder, + workspace: ref.read(libraryWorkspaceProvider), + ), + ); }, ), ]; diff --git a/lib/widgets/save_and_load_button.dart b/lib/widgets/save_and_load_button.dart index 3f117688..510fa856 100644 --- a/lib/widgets/save_and_load_button.dart +++ b/lib/widgets/save_and_load_button.dart @@ -15,6 +15,7 @@ import 'package:icarus/providers/map_provider.dart'; import 'package:icarus/providers/screenshot_provider.dart'; import 'package:icarus/providers/strategy_page_session_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/services/cloud_strategy_export.dart'; import 'package:icarus/strategy/strategy_import_export.dart'; import 'package:icarus/strategy/strategy_models.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; @@ -81,7 +82,7 @@ class _SaveAndLoadButtonState extends ConsumerState { final exporter = StrategyImportExportService(ref); switch (strategy.source!) { case StrategySource.cloud: - await exporter.exportCloudStrategy(strategyId); + await runCloudStrategyExport(ref, strategyId); case StrategySource.local: await exporter.exportFile(strategyId); } diff --git a/lib/widgets/strategy_tile/strategy_tile.dart b/lib/widgets/strategy_tile/strategy_tile.dart index 4b7992ad..f5ea5cd6 100644 --- a/lib/widgets/strategy_tile/strategy_tile.dart +++ b/lib/widgets/strategy_tile/strategy_tile.dart @@ -7,6 +7,7 @@ import 'package:icarus/const/settings.dart'; import 'package:icarus/providers/library_context_menu_provider.dart'; import 'package:icarus/providers/pinned_items_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/services/cloud_strategy_export.dart'; import 'package:icarus/strategy/strategy_import_export.dart'; import 'package:icarus/strategy/strategy_models.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; @@ -497,7 +498,7 @@ class _StrategyTileState extends ConsumerState { } if (_isCloud) { - await StrategyImportExportService(ref).exportCloudStrategy(_strategyId); + await runCloudStrategyExport(ref, _strategyId); return; } diff --git a/test/providers/cloud_library_action_providers_test.dart b/test/providers/cloud_library_action_providers_test.dart new file mode 100644 index 00000000..8a05f09a --- /dev/null +++ b/test/providers/cloud_library_action_providers_test.dart @@ -0,0 +1,495 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive_ce/hive.dart'; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/collab/convex_strategy_repository.dart'; +import 'package:icarus/collab/generated/generated.dart'; +import 'package:icarus/collab/transport/convex_transport.dart'; +import 'package:icarus/const/hive_boxes.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/providers/collab/remote_library_provider.dart'; +import 'package:icarus/providers/folder_provider.dart'; +import 'package:icarus/providers/library_workspace_provider.dart'; +import 'package:icarus/providers/pinned_items_provider.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/services/cloud_library_action.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory tempDirectory; + + setUp(() async { + tempDirectory = await Directory.systemTemp.createTemp( + 'icarus-cloud-library-actions-', + ); + Hive.init(tempDirectory.path); + await Hive.openBox(HiveBoxNames.pinnedItemsBox); + }); + + tearDown(() async { + await Hive.close(); + if (await tempDirectory.exists()) { + await tempDirectory.delete(recursive: true); + } + }); + + test('failed cloud folder delete preserves selection, pin, and streams', + () async { + final repository = _ActionRepository()..failDeleteFolder = true; + final harness = _Harness(repository); + addTearDown(harness.dispose); + final notifier = harness.container.read(folderProvider.notifier); + notifier.updateID('folder-1'); + await harness.container + .read(pinnedItemsProvider.notifier) + .togglePin('folder-1'); + + final result = await notifier.deleteFolder( + 'folder-1', + workspace: LibraryWorkspace.cloud, + ); + await _pumpMicrotasks(); + + expect(result.didSucceed, isFalse); + expect(result.userMessage, "Couldn't delete this cloud folder. Try again."); + expect(result.userMessage, isNot(contains(_ActionRepository.secret))); + expect(harness.container.read(folderProvider), 'folder-1'); + expect(harness.container.read(pinnedItemsProvider), contains('folder-1')); + expect(harness.cloudFolderBuilds, 1); + expect(harness.allCloudFolderBuilds, 1); + expect(harness.cloudStrategyBuilds, 1); + expect(harness.messages, isEmpty); + }); + + test('successful cloud folder delete mutates local UI state once', () async { + final repository = _ActionRepository(); + final harness = _Harness(repository); + addTearDown(harness.dispose); + final notifier = harness.container.read(folderProvider.notifier); + notifier.updateID('folder-1'); + await harness.container + .read(pinnedItemsProvider.notifier) + .togglePin('folder-1'); + + final result = await notifier.deleteFolder( + 'folder-1', + workspace: LibraryWorkspace.cloud, + ); + await _pumpMicrotasks(); + + expect(result.didSucceed, isTrue); + expect(repository.deleteFolderCalls, 1); + expect(harness.container.read(folderProvider), isNull); + expect( + harness.container.read(pinnedItemsProvider), + isNot(contains('folder-1')), + ); + expect(harness.cloudFolderBuilds, 2); + expect(harness.allCloudFolderBuilds, 2); + expect(harness.cloudStrategyBuilds, 2); + }); + + test('cloud folder update is awaitable and invalidates only after success', + () async { + final gate = Completer(); + final repository = _ActionRepository()..updateFolderGate = gate; + final harness = _Harness(repository); + addTearDown(harness.dispose); + final folder = _folder('folder-1'); + + final pending = harness.container.read(folderProvider.notifier).editFolder( + folder: folder, + newName: 'Retakes', + newIconId: folder.iconId, + newColor: folder.color, + newCustomColor: folder.customColor, + workspace: LibraryWorkspace.cloud, + ); + var completed = false; + pending.then((_) => completed = true); + await _pumpMicrotasks(); + expect(completed, isFalse); + expect(harness.cloudFolderBuilds, 1); + + gate.complete(); + final result = await pending; + await _pumpMicrotasks(); + + expect(result.didSucceed, isTrue); + expect(repository.updateFolderCalls, 1); + expect(harness.cloudFolderBuilds, 2); + expect(harness.allCloudFolderBuilds, 2); + }); + + test('failed cloud folder update preserves model and does not invalidate', + () async { + final repository = _ActionRepository()..failUpdateFolder = true; + final harness = _Harness(repository); + addTearDown(harness.dispose); + final folder = _folder('folder-1'); + + final result = + await harness.container.read(folderProvider.notifier).editFolder( + folder: folder, + newName: 'Injected ${_ActionRepository.secret}', + newIconId: folder.iconId, + newColor: folder.color, + newCustomColor: folder.customColor, + workspace: LibraryWorkspace.cloud, + ); + await _pumpMicrotasks(); + + expect(result.didSucceed, isFalse); + expect(result.userMessage, isNot(contains(_ActionRepository.secret))); + expect(folder.name, 'Defaults'); + expect(harness.cloudFolderBuilds, 1); + expect(harness.allCloudFolderBuilds, 1); + expect(harness.messages, isEmpty); + }); + + test('failed cloud folder move is awaitable, visible, and does not refresh', + () async { + final gate = Completer(); + final repository = _ActionRepository() + ..moveFolderGate = gate + ..failMoveFolder = true; + final harness = _Harness(repository); + addTearDown(harness.dispose); + + final pending = + harness.container.read(folderProvider.notifier).moveToFolder( + folderID: 'folder-1', + parentID: 'folder-2', + workspace: LibraryWorkspace.cloud, + ); + var completed = false; + pending.then((_) => completed = true); + await _pumpMicrotasks(); + expect(completed, isFalse); + + gate.complete(); + final result = await pending; + await _pumpMicrotasks(); + + expect(result.didSucceed, isFalse); + expect(harness.cloudFolderBuilds, 1); + expect(harness.allCloudFolderBuilds, 1); + expect(harness.messages, ["Couldn't move this cloud folder. Try again."]); + expect(harness.messages.single, isNot(contains(_ActionRepository.secret))); + }); + + test('successful cloud folder move refreshes both folder views once', + () async { + final repository = _ActionRepository(); + final harness = _Harness(repository); + addTearDown(harness.dispose); + + final result = + await harness.container.read(folderProvider.notifier).moveToFolder( + folderID: 'folder-1', + parentID: 'folder-2', + workspace: LibraryWorkspace.cloud, + ); + await _pumpMicrotasks(); + + expect(result.didSucceed, isTrue); + expect(repository.moveFolderCalls, 1); + expect(harness.cloudFolderBuilds, 2); + expect(harness.allCloudFolderBuilds, 2); + expect(harness.messages, isEmpty); + }); + + test('cloud strategy delete keeps pin and stream when the server rejects it', + () async { + final repository = _ActionRepository()..failDeleteStrategy = true; + final harness = _Harness(repository); + addTearDown(harness.dispose); + await harness.container + .read(pinnedItemsProvider.notifier) + .togglePin('strategy-1'); + + final result = await harness.container + .read(strategyProvider.notifier) + .deleteStrategy('strategy-1', source: StrategySource.cloud); + await _pumpMicrotasks(); + + expect(result.didSucceed, isFalse); + expect(result.userMessage, isNot(contains(_ActionRepository.secret))); + expect(harness.container.read(pinnedItemsProvider), contains('strategy-1')); + expect(harness.cloudStrategyBuilds, 1); + expect(harness.messages, isEmpty); + }); + + test('cloud strategy delete removes pin and refreshes only after success', + () async { + final repository = _ActionRepository(); + final harness = _Harness(repository); + addTearDown(harness.dispose); + await harness.container + .read(pinnedItemsProvider.notifier) + .togglePin('strategy-1'); + + final result = await harness.container + .read(strategyProvider.notifier) + .deleteStrategy('strategy-1', source: StrategySource.cloud); + await _pumpMicrotasks(); + + expect(result.didSucceed, isTrue); + expect(repository.deleteStrategyCalls, 1); + expect( + harness.container.read(pinnedItemsProvider), + isNot(contains('strategy-1')), + ); + expect(harness.cloudStrategyBuilds, 2); + }); + + test('failed cloud strategy move is awaitable, visible, and does not refresh', + () async { + final gate = Completer(); + final repository = _ActionRepository() + ..moveStrategyGate = gate + ..failMoveStrategy = true; + final harness = _Harness(repository); + addTearDown(harness.dispose); + + final pending = + harness.container.read(strategyProvider.notifier).moveToFolder( + strategyID: 'strategy-1', + parentID: 'folder-2', + source: StrategySource.cloud, + ); + var completed = false; + pending.then((_) => completed = true); + await _pumpMicrotasks(); + expect(completed, isFalse); + + gate.complete(); + final result = await pending; + await _pumpMicrotasks(); + + expect(result.didSucceed, isFalse); + expect(harness.cloudStrategyBuilds, 1); + expect( + harness.messages, + ["Couldn't move this cloud strategy. Try again."], + ); + expect(harness.messages.single, isNot(contains(_ActionRepository.secret))); + }); + + test('successful cloud strategy move refreshes the library once', () async { + final repository = _ActionRepository(); + final harness = _Harness(repository); + addTearDown(harness.dispose); + + final result = + await harness.container.read(strategyProvider.notifier).moveToFolder( + strategyID: 'strategy-1', + parentID: 'folder-2', + source: StrategySource.cloud, + ); + await _pumpMicrotasks(); + + expect(result.didSucceed, isTrue); + expect(repository.moveStrategyCalls, 1); + expect(harness.cloudStrategyBuilds, 2); + expect(harness.messages, isEmpty); + }); +} + +class _Harness { + _Harness(this.repository) { + container = ProviderContainer( + overrides: [ + authProvider.overrideWith(() => auth), + libraryWorkspaceProvider.overrideWith(_CloudWorkspaceNotifier.new), + convexStrategyRepositoryProvider.overrideWithValue(repository), + cloudLibraryActionReporterProvider.overrideWithValue( + CloudLibraryActionReporter( + showMessage: messages.add, + reportTechnicalFailure: ({ + required source, + required error, + required stackTrace, + }) {}, + ), + ), + cloudFoldersProvider.overrideWith((_) { + cloudFolderBuilds += 1; + return Stream.value(const []); + }), + cloudAllFoldersProvider.overrideWith((_) { + allCloudFolderBuilds += 1; + return Stream.value(const []); + }), + cloudStrategiesProvider.overrideWith((_) { + cloudStrategyBuilds += 1; + return Stream.value(const []); + }), + ], + ); + container.listen(cloudFoldersProvider, (_, __) {}, fireImmediately: true); + container.listen( + cloudAllFoldersProvider, + (_, __) {}, + fireImmediately: true, + ); + container.listen( + cloudStrategiesProvider, + (_, __) {}, + fireImmediately: true, + ); + } + + final _ActionRepository repository; + final _ReadyAuthProvider auth = _ReadyAuthProvider(); + final List messages = []; + late final ProviderContainer container; + int cloudFolderBuilds = 0; + int allCloudFolderBuilds = 0; + int cloudStrategyBuilds = 0; + + void dispose() => container.dispose(); +} + +class _CloudWorkspaceNotifier extends LibraryWorkspaceNotifier { + @override + LibraryWorkspace build() => LibraryWorkspace.cloud; +} + +class _ReadyAuthProvider extends AuthProvider { + @override + AppAuthState build() => const AppAuthState( + isLoading: false, + isAuthenticated: true, + isConvexUserReady: true, + convexAuthStatus: ConvexAuthStatus.ready, + user: null, + ); +} + +class _ActionRepository extends ConvexStrategyRepository { + _ActionRepository() : super(IcarusConvexApi(_UnusedTransport())); + + static const secret = 'Bearer super-secret-backend-detail'; + + bool failDeleteFolder = false; + bool failUpdateFolder = false; + bool failMoveFolder = false; + bool failDeleteStrategy = false; + bool failMoveStrategy = false; + Completer? updateFolderGate; + Completer? moveFolderGate; + Completer? moveStrategyGate; + int deleteFolderCalls = 0; + int updateFolderCalls = 0; + int deleteStrategyCalls = 0; + int moveFolderCalls = 0; + int moveStrategyCalls = 0; + + @override + Future deleteFolder(String folderPublicId) async { + deleteFolderCalls += 1; + if (failDeleteFolder) throw StateError(secret); + } + + @override + Future updateFolder({ + required String folderPublicId, + String? name, + int? iconId, + int? iconCodePoint, + String? iconFontFamily, + bool clearIconFontFamily = false, + String? iconFontPackage, + bool clearIconFontPackage = false, + String? color, + int? customColorValue, + bool clearCustomColorValue = false, + }) async { + updateFolderCalls += 1; + await updateFolderGate?.future; + if (failUpdateFolder) throw StateError(secret); + } + + @override + Future moveFolder({ + required String folderPublicId, + String? parentFolderPublicId, + }) async { + moveFolderCalls += 1; + await moveFolderGate?.future; + if (failMoveFolder) throw StateError(secret); + } + + @override + Future fetchShell(String strategyPublicId) async { + final now = DateTime.utc(2026); + return RemoteStrategyShell( + header: RemoteStrategyHeader( + publicId: strategyPublicId, + name: 'A Split', + mapData: 'Ascent', + revision: 4, + createdAt: now, + updatedAt: now, + ), + pages: const [], + ); + } + + @override + Future deleteStrategy({ + required String strategyPublicId, + required int expectedRevision, + }) async { + deleteStrategyCalls += 1; + if (failDeleteStrategy) throw StateError(secret); + } + + @override + Future moveStrategy({ + required String strategyPublicId, + String? folderPublicId, + required int expectedRevision, + }) async { + moveStrategyCalls += 1; + await moveStrategyGate?.future; + if (failMoveStrategy) throw StateError(secret); + } +} + +Folder _folder(String id) => Folder( + id: id, + name: 'Defaults', + iconId: 0, + dateCreated: DateTime.utc(2026), + color: FolderColor.generic, + ); + +Future _pumpMicrotasks() async { + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); +} + +class _UnusedTransport implements ConvexTransport { + @override + Future action(String name, ConvexObject args) => + throw UnimplementedError(); + + @override + Future mutation(String name, ConvexObject args) => + throw UnimplementedError(); + + @override + Future query(String name, ConvexObject args) => + throw UnimplementedError(); + + @override + Stream subscribe(String name, ConvexObject args) => + throw UnimplementedError(); +} diff --git a/test/providers/folder_provider_test.dart b/test/providers/folder_provider_test.dart index 0fcd6863..7ec217ce 100644 --- a/test/providers/folder_provider_test.dart +++ b/test/providers/folder_provider_test.dart @@ -5,8 +5,11 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:icarus/collab/convex_strategy_repository.dart'; import 'package:icarus/collab/generated/generated.dart'; import 'package:icarus/collab/transport/convex_transport.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/providers/collab/remote_library_provider.dart'; import 'package:icarus/providers/folder_provider.dart'; import 'package:icarus/providers/library_workspace_provider.dart'; +import 'package:icarus/providers/pinned_items_provider.dart'; void main() { test('failed cloud folder deletion preserves the selected folder', () async { @@ -50,11 +53,35 @@ ProviderContainer _createContainer(ConvexStrategyRepository repository) { return ProviderContainer( overrides: [ libraryWorkspaceProvider.overrideWith(_CloudWorkspaceNotifier.new), + pinnedItemsProvider.overrideWith(_MemoryPinnedItemsProvider.new), convexStrategyRepositoryProvider.overrideWithValue(repository), + authProvider.overrideWith(_ReadyAuthProvider.new), + cloudFoldersProvider.overrideWith((_) => Stream.value(const [])), + cloudAllFoldersProvider.overrideWith((_) => Stream.value(const [])), + cloudStrategiesProvider.overrideWith((_) => Stream.value(const [])), ], ); } +class _MemoryPinnedItemsProvider extends PinnedItemsProvider { + @override + Map build() => const {}; + + @override + Future removePin(String id) async {} +} + +class _ReadyAuthProvider extends AuthProvider { + @override + AppAuthState build() => const AppAuthState( + isLoading: false, + isAuthenticated: true, + isConvexUserReady: true, + convexAuthStatus: ConvexAuthStatus.ready, + user: null, + ); +} + class _CloudWorkspaceNotifier extends LibraryWorkspaceNotifier { @override LibraryWorkspace build() => LibraryWorkspace.cloud; diff --git a/test/widgets/cloud_library_action_dialogs_test.dart b/test/widgets/cloud_library_action_dialogs_test.dart new file mode 100644 index 00000000..16829b5f --- /dev/null +++ b/test/widgets/cloud_library_action_dialogs_test.dart @@ -0,0 +1,362 @@ +import 'dart:async'; +import 'dart:collection'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/collab/convex_client.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/providers/folder_provider.dart'; +import 'package:icarus/providers/library_workspace_provider.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/services/cloud_library_action.dart'; +import 'package:icarus/services/cloud_strategy_export.dart'; +import 'package:icarus/strategy/strategy_import_export.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; +import 'package:icarus/widgets/dialogs/delete_folder_alert_dialog.dart'; +import 'package:icarus/widgets/dialogs/strategy/delete_strategy_alert_dialog.dart'; +import 'package:icarus/widgets/folder_edit_dialog.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +void main() { + testWidgets('folder edit failure stays open, is safe, and can retry', + (tester) async { + final firstAttempt = Completer(); + final folderProvider = _ControlledFolderProvider() + ..editResults.add(firstAttempt.future) + ..editResults.add(Future.value(CloudLibraryActionResult.succeeded)); + await _pumpDialogLauncher( + tester, + overrides: [ + _folderProviderOverride(folderProvider), + ], + dialog: FolderEditDialog(folder: _folder()), + ); + + await tester.tap(find.byKey(const ValueKey('folder-edit-submit'))); + await tester.tap(find.byKey(const ValueKey('folder-edit-submit'))); + await tester.pump(); + expect(folderProvider.editCalls, 1); + expect(find.text('Saving...'), findsOneWidget); + + firstAttempt.complete( + CloudLibraryActionResult.failed( + "Couldn't update this cloud folder. Try again.", + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(FolderEditDialog), findsOneWidget); + expect( + find.text("Couldn't update this cloud folder. Try again."), + findsOneWidget, + ); + expect(find.text(_secret), findsNothing); + + await tester.tap(find.byKey(const ValueKey('folder-edit-submit'))); + await tester.pumpAndSettle(); + expect(folderProvider.editCalls, 2); + expect(find.byType(FolderEditDialog), findsNothing); + }); + + testWidgets('folder delete failure stays open, is safe, and can retry', + (tester) async { + final firstAttempt = Completer(); + final folderProvider = _ControlledFolderProvider() + ..deleteResults.add(firstAttempt.future) + ..deleteResults.add(Future.value(CloudLibraryActionResult.succeeded)); + await _pumpDialogLauncher( + tester, + overrides: [_folderProviderOverride(folderProvider)], + dialog: DeleteFolderAlertDialog( + folder: _folder(), + workspace: LibraryWorkspace.cloud, + ), + ); + + await tester.tap(find.byKey(const ValueKey('delete-folder-confirm'))); + await tester.tap(find.byKey(const ValueKey('delete-folder-confirm'))); + await tester.pump(); + expect(folderProvider.deleteCalls, 1); + expect(find.text('Deleting...'), findsOneWidget); + + firstAttempt.complete( + CloudLibraryActionResult.failed( + "Couldn't delete this cloud folder. Try again.", + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(DeleteFolderAlertDialog), findsOneWidget); + expect( + find.text("Couldn't delete this cloud folder. Try again."), + findsOneWidget, + ); + expect(find.text(_secret), findsNothing); + + await tester.tap(find.byKey(const ValueKey('delete-folder-confirm'))); + await tester.pumpAndSettle(); + expect(folderProvider.deleteCalls, 2); + expect(find.byType(DeleteFolderAlertDialog), findsNothing); + }); + + testWidgets('strategy delete failure stays open, is safe, and can retry', + (tester) async { + final firstAttempt = Completer(); + final strategyProvider = _ControlledStrategyProvider() + ..deleteResults.add(firstAttempt.future) + ..deleteResults.add(Future.value(CloudLibraryActionResult.succeeded)); + await _pumpDialogLauncher( + tester, + overrides: [_strategyProviderOverride(strategyProvider)], + dialog: const DeleteStrategyAlertDialog( + strategyID: 'strategy-1', + name: 'A Split', + source: StrategySource.cloud, + ), + ); + + await tester.tap(find.byKey(const ValueKey('delete-strategy-confirm'))); + await tester.tap(find.byKey(const ValueKey('delete-strategy-confirm'))); + await tester.pump(); + expect(strategyProvider.deleteCalls, 1); + expect(find.text('Deleting...'), findsOneWidget); + + firstAttempt.complete( + CloudLibraryActionResult.failed( + "Couldn't delete this cloud strategy. Try again.", + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(DeleteStrategyAlertDialog), findsOneWidget); + expect( + find.text("Couldn't delete this cloud strategy. Try again."), + findsOneWidget, + ); + expect(find.text(_secret), findsNothing); + + await tester.tap(find.byKey(const ValueKey('delete-strategy-confirm'))); + await tester.pumpAndSettle(); + expect(strategyProvider.deleteCalls, 2); + expect(find.byType(DeleteStrategyAlertDialog), findsNothing); + }); + + testWidgets('cloud export failure reports one generic message', + (tester) async { + final messages = []; + var exportCalls = 0; + await tester.pumpWidget( + ProviderScope( + overrides: [ + cloudStrategyExporterProvider.overrideWithValue((_) async { + exportCalls += 1; + throw StateError(_secret); + }), + cloudLibraryActionReporterProvider.overrideWithValue( + CloudLibraryActionReporter( + showMessage: messages.add, + reportTechnicalFailure: ({ + required source, + required error, + required stackTrace, + }) {}, + ), + ), + authProvider.overrideWith(_ReadyAuthProvider.new), + ], + child: const ShadApp( + home: Scaffold(body: _CloudExportInvoker()), + ), + ), + ); + + await tester.tap(find.text('Export cloud strategy')); + await tester.pumpAndSettle(); + + expect(exportCalls, 1); + expect(messages, ["Couldn't export this cloud strategy. Try again."]); + expect(messages.single, isNot(contains(_secret))); + }); + + testWidgets('cancelled cloud export stays silent', (tester) async { + final messages = []; + await tester.pumpWidget( + ProviderScope( + overrides: [ + cloudStrategyExporterProvider.overrideWithValue((_) async => false), + cloudLibraryActionReporterProvider.overrideWithValue( + CloudLibraryActionReporter( + showMessage: messages.add, + reportTechnicalFailure: ({ + required source, + required error, + required stackTrace, + }) {}, + ), + ), + authProvider.overrideWith(_ReadyAuthProvider.new), + ], + child: const ShadApp( + home: Scaffold(body: _CloudExportInvoker()), + ), + ), + ); + + await tester.tap(find.text('Export cloud strategy')); + await tester.pumpAndSettle(); + + expect(messages, isEmpty); + }); + + test('auth failures use the incident path without a generic message', + () async { + final messages = []; + var authReports = 0; + final reporter = CloudLibraryActionReporter( + showMessage: messages.add, + reportTechnicalFailure: ({ + required source, + required error, + required stackTrace, + }) {}, + ); + + final result = await reporter.run( + action: () async => throw const ConvexClientFunctionError( + rawCode: 'UNAUTHENTICATED', + message: 'Authentication required', + data: null, + ), + source: 'test:auth', + failureMessage: 'This must not be shown.', + showFailureMessage: true, + reportAuthenticationFailure: (_, __) async => authReports += 1, + ); + + expect(result.status, CloudLibraryActionStatus.authenticationRequired); + expect(authReports, 1); + expect(messages, isEmpty); + }); +} + +const _secret = 'Bearer super-secret-backend-detail'; + +Override _folderProviderOverride(_ControlledFolderProvider notifier) => + folderProvider.overrideWith(() => notifier); + +Override _strategyProviderOverride(_ControlledStrategyProvider notifier) => + strategyProvider.overrideWith(() => notifier); + +Future _pumpDialogLauncher( + WidgetTester tester, { + required List overrides, + required Widget dialog, +}) async { + await tester.pumpWidget( + ProviderScope( + overrides: overrides, + child: ShadApp( + home: Scaffold( + body: Builder( + builder: (context) => ShadButton( + onPressed: () => showShadDialog( + context: context, + builder: (_) => dialog, + ), + child: const Text('Open dialog'), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open dialog')); + await tester.pumpAndSettle(); +} + +class _ControlledFolderProvider extends FolderProvider { + final Queue> editResults = Queue(); + final Queue> deleteResults = Queue(); + int editCalls = 0; + int deleteCalls = 0; + + @override + String? build() => null; + + @override + Future editFolder({ + required Folder folder, + required String newName, + required int newIconId, + required FolderColor newColor, + required Color? newCustomColor, + LibraryWorkspace? workspace, + }) { + editCalls += 1; + return editResults.removeFirst(); + } + + @override + Future deleteFolder( + String folderID, { + LibraryWorkspace? workspace, + }) { + deleteCalls += 1; + return deleteResults.removeFirst(); + } +} + +class _ControlledStrategyProvider extends StrategyProvider { + final Queue> deleteResults = Queue(); + int deleteCalls = 0; + + @override + StrategyState build() => const StrategyState( + strategyId: null, + strategyName: null, + source: null, + storageDirectory: null, + isOpen: false, + ); + + @override + Future deleteStrategy( + String strategyID, { + StrategySource? source, + }) { + deleteCalls += 1; + return deleteResults.removeFirst(); + } +} + +class _CloudExportInvoker extends ConsumerWidget { + const _CloudExportInvoker(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return ShadButton( + onPressed: () => runCloudStrategyExport(ref, 'strategy-1'), + child: const Text('Export cloud strategy'), + ); + } +} + +class _ReadyAuthProvider extends AuthProvider { + @override + AppAuthState build() => const AppAuthState( + isLoading: false, + isAuthenticated: true, + isConvexUserReady: true, + convexAuthStatus: ConvexAuthStatus.ready, + user: null, + ); +} + +Folder _folder() => Folder( + id: 'folder-1', + name: 'Defaults', + iconId: 0, + dateCreated: DateTime.utc(2026), + color: FolderColor.generic, + );