diff --git a/lib/widgets/folder_content.dart b/lib/widgets/folder_content.dart index 95697815..d1d46fc2 100644 --- a/lib/widgets/folder_content.dart +++ b/lib/widgets/folder_content.dart @@ -23,9 +23,14 @@ import 'package:icarus/widgets/strategy_tile/strategy_tile.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; class FolderContent extends ConsumerWidget { - FolderContent({super.key, this.folder}); + FolderContent({ + super.key, + this.folder, + required this.onCreateStrategy, + }); final Folder? folder; + final VoidCallback onCreateStrategy; final TextEditingController searchController = TextEditingController(); static final strategiesListenable = @@ -129,14 +134,31 @@ class FolderContent extends ConsumerWidget { localStrategies: const [], cloudStrategies: _filterCloudStrategies(ref, strategies), isCloud: true, - showAddSharedItemAction: isSharedWithMe, + emptyStateKey: ValueKey( + isSharedWithMe ? 'shared-empty-state' : 'cloud-empty-state', + ), emptyStateIcon: isSharedWithMe ? Icons.people_outline : Icons.cloud_outlined, - emptyStateTitle: - isSharedWithMe ? 'No shared items yet' : 'No cloud strategies yet', + emptyStateTitle: isSharedWithMe + ? 'Nothing shared with you yet' + : 'Your cloud library is empty', emptyStateSubtitle: isSharedWithMe - ? 'Shared folders and strategies will appear here' - : 'Create a cloud strategy to start your online workspace', + ? 'Add a share link or code from a teammate to keep it here.' + : 'Create your first cloud strategy to keep it available across ' + 'your Icarus clients.', + emptyStateAction: isSharedWithMe + ? ShadButton( + key: const ValueKey('shared-empty-add-item'), + onPressed: () => showAddSharedItemDialog(context), + leading: const Icon(LucideIcons.link), + child: const Text('Add by Link or Code'), + ) + : ShadButton( + key: const ValueKey('cloud-empty-create-strategy'), + onPressed: onCreateStrategy, + leading: const Icon(Icons.add), + child: const Text('Create Cloud Strategy'), + ), ), ); } @@ -210,14 +232,16 @@ class FolderContent extends ConsumerWidget { required List localStrategies, required List cloudStrategies, required bool isCloud, - bool showAddSharedItemAction = false, + Key? emptyStateKey, IconData? emptyStateIcon, required String emptyStateTitle, required String emptyStateSubtitle, + Widget? emptyStateAction, }) { final hasStrategies = localStrategies.isNotEmpty || cloudStrategies.isNotEmpty; final Widget emptyState = Center( + key: emptyStateKey, child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 420), child: Column( @@ -244,13 +268,9 @@ class FolderContent extends ConsumerWidget { color: Settings.tacticalVioletTheme.mutedForeground, ), ), - if (showAddSharedItemAction) ...[ + if (emptyStateAction != null) ...[ const SizedBox(height: 18), - ShadButton( - onPressed: () => showAddSharedItemDialog(context), - leading: const Icon(LucideIcons.link), - child: const Text('Add by Link or Code'), - ), + emptyStateAction, ], ], ), diff --git a/lib/widgets/folder_navigator.dart b/lib/widgets/folder_navigator.dart index 22387b9b..c0ea58e6 100644 --- a/lib/widgets/folder_navigator.dart +++ b/lib/widgets/folder_navigator.dart @@ -432,7 +432,10 @@ class _FolderNavigatorState extends ConsumerState { switchOutCurve: Curves.easeOutCubic, child: KeyedSubtree( key: ValueKey('$workspace/$cloudSection'), - child: FolderContent(folder: currentFolder), + child: FolderContent( + folder: currentFolder, + onCreateStrategy: showCreateDialog, + ), ), ), ), @@ -521,7 +524,7 @@ class _LibraryNavigationRailState extends ConsumerState { : 'Log in to sync strategies', selected: workspace == LibraryWorkspace.cloud && cloudSection == CloudLibrarySection.home, - onTap: cloudAvailable ? () => _selectCloudHome() : null, + onTap: cloudAvailable ? () => _selectCloudHome() : _showAuthDialog, ), _LibraryRailItemData( key: const ValueKey('library-shared'), @@ -530,10 +533,10 @@ class _LibraryNavigationRailState extends ConsumerState { semanticsLabel: 'Shared library', description: cloudAvailable ? 'Strategies shared with you' - : 'Log in to view shared strats', + : 'Log in to view shared strategies', selected: workspace == LibraryWorkspace.cloud && cloudSection == CloudLibrarySection.sharedWithMe, - onTap: cloudAvailable ? () => _selectShared() : null, + onTap: cloudAvailable ? () => _selectShared() : _showAuthDialog, ), _LibraryRailItemData( key: const ValueKey('library-community'), @@ -687,6 +690,13 @@ class _LibraryNavigationRailState extends ConsumerState { ref.read(folderProvider.notifier).updateID(null); } + void _showAuthDialog() { + showDialog( + context: context, + builder: (_) => const AuthDialog(), + ); + } + void _selectCloudHome() { ref.read(libraryWorkspaceProvider.notifier).select(LibraryWorkspace.cloud); ref diff --git a/test/widgets/cloud_beta_automation_semantics_test.dart b/test/widgets/cloud_beta_automation_semantics_test.dart index 6efe6de2..6b620baa 100644 --- a/test/widgets/cloud_beta_automation_semantics_test.dart +++ b/test/widgets/cloud_beta_automation_semantics_test.dart @@ -72,6 +72,32 @@ void main() { expect(_semantics('Community library').properties.onTap, isNotNull); expect(_semantics('Log in to Icarus').properties.onTap, isNotNull); }); + + testWidgets('signed-out cloud destinations open login', (tester) async { + await tester.pumpWidget( + _testApp( + const SizedBox( + width: 220, + height: 800, + child: LibraryNavigationRail(), + ), + ), + ); + + await tester.tap(find.byKey(const ValueKey('library-cloud'))); + await tester.pumpAndSettle(); + + expect(find.byType(AuthDialog), findsOneWidget); + expect(find.text('Sign in'), findsAtLeastNWidgets(1)); + + Navigator.of(tester.element(find.byType(AuthDialog))).pop(); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const ValueKey('library-shared'))); + await tester.pumpAndSettle(); + + expect(find.byType(AuthDialog), findsOneWidget); + }); } Semantics _semantics(String label) { diff --git a/test/widgets/cloud_library_empty_states_test.dart b/test/widgets/cloud_library_empty_states_test.dart new file mode 100644 index 00000000..362dd4de --- /dev/null +++ b/test/widgets/cloud_library_empty_states_test.dart @@ -0,0 +1,108 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/const/coordinate_system.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/providers/collab/remote_library_provider.dart'; +import 'package:icarus/providers/library_workspace_provider.dart'; +import 'package:icarus/widgets/dialogs/share_links_dialog.dart'; +import 'package:icarus/widgets/folder_content.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +void main() { + setUp(() { + CoordinateSystem(playAreaSize: const Size(1280, 720)); + }); + + testWidgets('empty Cloud leads to creating a cloud strategy', (tester) async { + var createCount = 0; + await tester.pumpWidget( + _cloudApp( + FolderContent(onCreateStrategy: () => createCount++), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('cloud-empty-state')), findsOneWidget); + expect(find.text('Your cloud library is empty'), findsOneWidget); + expect( + find.byKey(const ValueKey('cloud-empty-create-strategy')), + findsOneWidget, + ); + + await tester.tap( + find.byKey(const ValueKey('cloud-empty-create-strategy')), + ); + expect(createCount, 1); + }); + + testWidgets('empty Shared leads to adding a link or code', (tester) async { + await tester.pumpWidget( + _cloudApp( + FolderContent(onCreateStrategy: () {}), + section: CloudLibrarySection.sharedWithMe, + ), + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('shared-empty-state')), findsOneWidget); + expect(find.text('Nothing shared with you yet'), findsOneWidget); + expect( + find.byKey(const ValueKey('shared-empty-add-item')), + findsOneWidget, + ); + + await tester.tap(find.byKey(const ValueKey('shared-empty-add-item'))); + await tester.pumpAndSettle(); + + expect(find.byType(AddSharedItemDialog), findsOneWidget); + }); +} + +Widget _cloudApp( + Widget child, { + CloudLibrarySection section = CloudLibrarySection.home, +}) { + return ProviderScope( + overrides: [ + authProvider.overrideWith(_CloudReadyAuthProvider.new), + libraryWorkspaceProvider.overrideWith(_CloudWorkspaceNotifier.new), + cloudLibrarySectionProvider.overrideWith( + () => _CloudSectionNotifier(section), + ), + cloudFoldersProvider.overrideWith( + (_) => Stream.value(const []), + ), + cloudStrategiesProvider.overrideWith( + (_) => Stream.value(const []), + ), + ], + child: ShadApp(home: Scaffold(body: child)), + ); +} + +class _CloudReadyAuthProvider 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; +} + +class _CloudSectionNotifier extends CloudLibrarySectionNotifier { + _CloudSectionNotifier(this._section); + + final CloudLibrarySection _section; + + @override + CloudLibrarySection build() => _section; +}