diff --git a/CONTEXT.md b/CONTEXT.md index ef6dd078..d76e2e85 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -41,6 +41,14 @@ each page held for the step duration with full-fidelity page transitions between them. _Avoid_: video sequencing, movie export +**Library tab**: +One of the three destinations in the library's title strip. *My Library* is +the user's own strategies and folders from every store, shown together; +*Shared* is what teammates shared with them; *Community* is the public space. +Whether a strategy is on this device, in the cloud, or both is a badge on the +strategy, not a place the user goes. +_Avoid_: This Computer, workspace (in user-facing copy) + **Lineup**: A saved ability setup (position/aim reference) attached to a page, grouped into lineup groups. diff --git a/DESIGN.md b/DESIGN.md index 38b24777..8f61c07f 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -116,6 +116,11 @@ components: textColor: "{colors.tactical-foreground}" rounded: "{rounded.panel}" width: "345px" + title-strip: + backgroundColor: "{colors.tactical-card}" + textColor: "{colors.tactical-foreground}" + height: "40px" + controlHeight: "28px" folder-card: backgroundColor: "{colors.tactical-card}" textColor: "{colors.tactical-foreground}" @@ -135,6 +140,11 @@ Icarus is a tactical workbench: dark, dense, map-first. The canvas and the tacti - Spacing uses the 8/10/12/16/24px steps from the frontmatter. Radii: 8px controls, 12px panels, 16px cards, 22px dialogs. - Transitions run 150-250ms and must communicate a state change (hover, selection, reveal, loading). No motion for its own sake. +## Window chrome + +- Desktop builds hide the native title bar. Each top-level screen draws its own 40px strip (`lib/widgets/window_chrome.dart`): macOS keeps its traffic lights, so the strip leaves a 78px inset on the left; Windows and Linux get app-drawn caption buttons on the right; the strip is the drag handle. Web renders the same strip with no inset and no buttons. +- The library strip holds the three tabs on the left and only search, sort, New, and the account on the right. Nothing else goes in it. Inside a folder, the breadcrumb lives in the content area, not the strip. + ## Named rules **The One Command Color Rule.** Violet marks current action, selection, focus, and primary commands — nothing else. If violet appears somewhere that isn't actionable or active, it's wrong. diff --git a/TODO.md b/TODO.md index e6a8ef89..65aaf344 100644 --- a/TODO.md +++ b/TODO.md @@ -24,11 +24,12 @@ expansion (`custom_search_field.dart`), shimmer skeleton (`strategy_view_skeleto ## 2. Folder navigator side rail — `lib/widgets/folder_navigator_sidebar.dart` -> Note: verification revealed `FolderNavigatorSidebar` is not mounted anywhere — -> the visible rail is `_LibraryNavigationRail` in `folder_navigator.dart`. Both -> were slimmed (sidebar 288→240px / rail 226→184px, smaller icons, single-line -> labels with tooltips). Mounting or deleting the unused sidebar widget is a -> separate product decision. +> Superseded (2026-09): the hover rail and the never-mounted sidebar are gone. +> The library's only chrome is now the title strip +> (`lib/widgets/library_title_strip.dart`) with three tabs: My Library, Shared, +> Community. "This Computer" is no longer a place; local strategies sit in +> My Library next to cloud ones with an "On this device" badge. The items below +> are kept for history. - [x] Narrow the panel (~288px → ~240px); reduce top-stack chrome - [x] Collapse the two stacked sort selects into one compact row (or move sorting into @@ -124,6 +125,6 @@ Outstanding follow-ups: against the current deployment. Likely deployment skew — this branch's typed-payload Convex functions haven't been deployed. Deploy `convex/` together with merging this branch, then re-verify opening cloud strategies. -- `FolderNavigatorSidebar` remains unmounted (see §2 note). +- `FolderNavigatorSidebar` was deleted with the rail (see §2 note). - Cloud "Create Cloud Strategy" goes straight to an Ascent editor with no map picker (matches local create behavior — decide if a picker is wanted). diff --git a/assets/brand/icarus-wordmark.svg b/assets/brand/icarus-wordmark.svg new file mode 100644 index 00000000..580f6b69 --- /dev/null +++ b/assets/brand/icarus-wordmark.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/docs/mockups/library-nav-alternatives.html b/docs/mockups/library-nav-alternatives.html new file mode 100644 index 00000000..0f34ed9c --- /dev/null +++ b/docs/mockups/library-nav-alternatives.html @@ -0,0 +1,426 @@ + + + + +Icarus library navigation: five alternatives + + + +

Icarus library navigation · · press 1–5 (b for 3b) or ←/→ to switch

+
+ +
+
+
+
+ + + + diff --git a/lib/providers/collab/remote_library_provider.dart b/lib/providers/collab/remote_library_provider.dart index 20a41239..5b7645ec 100644 --- a/lib/providers/collab/remote_library_provider.dart +++ b/lib/providers/collab/remote_library_provider.dart @@ -43,35 +43,35 @@ final cloudFolderTreeProvider = // the complete tree rather than the current folder's children. final cloudAllFoldersProvider = cloudFolderTreeProvider; +/// The folders at the open level of the cloud library, derived from the +/// tree. A plain derivation (not a second stream) so the tree never loses its +/// listener between rebuilds; an auto-disposed tree that re-subscribes on +/// every emission looks like a library that never finishes loading. final cloudFoldersProvider = - StreamProvider.autoDispose>((ref) async* { + Provider.autoDispose>>((ref) { final section = ref.watch(cloudLibrarySectionProvider); final parentFolderId = ref.watch(folderProvider); - final tree = ref.watch(cloudFolderTreeProvider); - final allFolders = switch (tree) { - AsyncData(:final value) => value, - AsyncError(:final error, :final stackTrace) => - Error.throwWithStackTrace(error, stackTrace), - _ => null, - }; - if (allFolders == null) return; - - final wantsShared = section == CloudLibrarySection.sharedWithMe; - final scopedFolders = allFolders - .where((entry) => - wantsShared ? entry.role != 'owner' : entry.role == 'owner') - .toList(growable: false); - if (parentFolderId != null && - !scopedFolders.any((entry) => entry.folder.id == parentFolderId)) { - ref - .read(folderProvider.notifier) - .updateWorkspaceFolderId(LibraryWorkspace.cloud, null); - yield const []; - return; - } - yield scopedFolders - .where((entry) => entry.folder.parentID == parentFolderId) - .toList(growable: false); + final folderNotifier = ref.read(folderProvider.notifier); + return ref.watch(cloudFolderTreeProvider).whenData((allFolders) { + final wantsShared = section == CloudLibrarySection.sharedWithMe; + final scopedFolders = allFolders + .where((entry) => + wantsShared ? entry.role != 'owner' : entry.role == 'owner') + .toList(growable: false); + if (parentFolderId != null && + !scopedFolders.any((entry) => entry.folder.id == parentFolderId)) { + // The open folder is not in this scope (deleted elsewhere, or it is a + // local folder while My Library shows both stores). Clear the cloud + // slot once this build settles. + Future.microtask(() { + folderNotifier.updateWorkspaceFolderId(LibraryWorkspace.cloud, null); + }); + return const []; + } + return scopedFolders + .where((entry) => entry.folder.parentID == parentFolderId) + .toList(growable: false); + }); }); final cloudStrategiesProvider = diff --git a/lib/providers/folder_provider.dart b/lib/providers/folder_provider.dart index 78c82b5f..492329bc 100644 --- a/lib/providers/folder_provider.dart +++ b/lib/providers/folder_provider.dart @@ -59,7 +59,7 @@ class FolderProvider extends Notifier { color: color.name, customColorValue: customColor?.toARGB32(), ); - ref.invalidate(cloudFoldersProvider); + ref.invalidate(cloudFolderTreeProvider); return newFolder; } catch (error, stackTrace) { await _maybeReportCloudUnauthenticated( @@ -87,6 +87,13 @@ class FolderProvider extends Notifier { updateWorkspaceFolderId(_currentWorkspace, id); } + /// Enters [folderId], which lives in [store]. My Library shows folders from + /// both stores side by side, so opening one also makes its store active. + void openFolder({required String folderId, required LibraryWorkspace store}) { + ref.read(libraryWorkspaceProvider.notifier).select(store); + updateWorkspaceFolderId(store, folderId); + } + void clearID() { updateWorkspaceFolderId(_currentWorkspace, null); } @@ -168,7 +175,7 @@ class FolderProvider extends Notifier { updateWorkspaceFolderId(LibraryWorkspace.cloud, null); } ref.invalidate(cloudFoldersProvider); - ref.invalidate(cloudAllFoldersProvider); + ref.invalidate(cloudFolderTreeProvider); ref.invalidate(cloudStrategiesProvider); return result; } @@ -232,7 +239,7 @@ class FolderProvider extends Notifier { if (!result.didSucceed) return result; ref.invalidate(cloudFoldersProvider); - ref.invalidate(cloudAllFoldersProvider); + ref.invalidate(cloudFolderTreeProvider); return result; } @@ -266,7 +273,7 @@ class FolderProvider extends Notifier { if (!result.didSucceed) return result; ref.invalidate(cloudFoldersProvider); - ref.invalidate(cloudAllFoldersProvider); + ref.invalidate(cloudFolderTreeProvider); return result; } @@ -290,13 +297,12 @@ class FolderProvider extends Notifier { source: source, failureMessage: failureMessage, showFailureMessage: showFailureMessage, - reportAuthenticationFailure: (error, stackTrace) => ref - .read(authProvider.notifier) - .reportConvexUnauthenticated( - source: source, - error: error, - stackTrace: stackTrace, - ), + reportAuthenticationFailure: (error, stackTrace) => + ref.read(authProvider.notifier).reportConvexUnauthenticated( + source: source, + error: error, + stackTrace: stackTrace, + ), ); } diff --git a/lib/providers/library_navigation_provider.dart b/lib/providers/library_navigation_provider.dart new file mode 100644 index 00000000..fca090df --- /dev/null +++ b/lib/providers/library_navigation_provider.dart @@ -0,0 +1,52 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/providers/folder_provider.dart'; +import 'package:icarus/providers/library_workspace_provider.dart'; + +final libraryNavigationProvider = Provider((ref) { + return LibraryNavigation(ref); +}); + +/// Moves between the title-strip tabs. Each call lands at the tab's root. +class LibraryNavigation { + LibraryNavigation(this._ref); + + final Ref _ref; + + /// Opens My Library. The cloud is the active store when it is reachable so + /// new strategies and folders land online; otherwise the local library is. + void showLibrary() { + final cloudAvailable = _ref.read(isCloudWorkspaceAvailableProvider); + _ref.read(libraryWorkspaceProvider.notifier).select( + cloudAvailable ? LibraryWorkspace.cloud : LibraryWorkspace.local, + ); + _ref + .read(cloudLibrarySectionProvider.notifier) + .select(CloudLibrarySection.home); + final folders = _ref.read(folderProvider.notifier); + folders.updateWorkspaceFolderId(LibraryWorkspace.local, null); + folders.updateWorkspaceFolderId(LibraryWorkspace.cloud, null); + } + + /// Opens Shared. Returns false, and changes nothing, when the cloud is not + /// reachable so the caller can ask the user to sign in. + bool showShared() { + if (!_ref.read(isCloudWorkspaceAvailableProvider)) { + return false; + } + _ref.read(libraryWorkspaceProvider.notifier).select(LibraryWorkspace.cloud); + _ref + .read(cloudLibrarySectionProvider.notifier) + .select(CloudLibrarySection.sharedWithMe); + _ref + .read(folderProvider.notifier) + .updateWorkspaceFolderId(LibraryWorkspace.cloud, null); + return true; + } + + void showCommunity() { + _ref + .read(libraryWorkspaceProvider.notifier) + .select(LibraryWorkspace.community); + _ref.read(folderProvider.notifier).updateID(null); + } +} diff --git a/lib/providers/library_rail_hover_provider.dart b/lib/providers/library_rail_hover_provider.dart deleted file mode 100644 index 1733a4a5..00000000 --- a/lib/providers/library_rail_hover_provider.dart +++ /dev/null @@ -1,3 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -final suppressLibraryRailHoverProvider = StateProvider((ref) => false); diff --git a/lib/providers/library_workspace_provider.dart b/lib/providers/library_workspace_provider.dart index 250a7130..132edf77 100644 --- a/lib/providers/library_workspace_provider.dart +++ b/lib/providers/library_workspace_provider.dart @@ -67,3 +67,26 @@ class CloudLibrarySectionNotifier extends Notifier { state = section; } } + +/// The three destinations in the library's title strip. `library` is the +/// user's own work from every store; `shared` is what teammates gave them; +/// `community` is the public space. The active store ([libraryWorkspaceProvider]) +/// stays an implementation detail behind the first tab. +enum LibraryTab { + library, + shared, + community, +} + +final libraryTabProvider = Provider((ref) { + final workspace = ref.watch(libraryWorkspaceProvider); + if (workspace == LibraryWorkspace.community) { + return LibraryTab.community; + } + final section = ref.watch(cloudLibrarySectionProvider); + if (workspace == LibraryWorkspace.cloud && + section == CloudLibrarySection.sharedWithMe) { + return LibraryTab.shared; + } + return LibraryTab.library; +}); diff --git a/lib/providers/strategy_provider.dart b/lib/providers/strategy_provider.dart index 9647821d..498eb9d5 100644 --- a/lib/providers/strategy_provider.dart +++ b/lib/providers/strategy_provider.dart @@ -1186,7 +1186,7 @@ class StrategyProvider extends Notifier { rethrow; } ref.invalidate(cloudStrategiesProvider); - ref.invalidate(cloudFoldersProvider); + ref.invalidate(cloudFolderTreeProvider); try { await openCloudStrategy(newID); } catch (error, stackTrace) { diff --git a/lib/services/desktop_runtime_native.dart b/lib/services/desktop_runtime_native.dart index a6c3d5e5..841fb5ab 100644 --- a/lib/services/desktop_runtime_native.dart +++ b/lib/services/desktop_runtime_native.dart @@ -26,6 +26,11 @@ Future initializeIcarusDesktopWindow(String title) async { minimumSize: const Size(1280, 720), center: true, title: title, + // The app draws its own title strip (lib/widgets/window_chrome.dart). + // macOS keeps its traffic lights; Windows and Linux get app-drawn + // caption buttons. + titleBarStyle: TitleBarStyle.hidden, + windowButtonVisibility: true, ); await windowManager.waitUntilReadyToShow(windowOptions, () async { await windowManager.show(); diff --git a/lib/strategy_view.dart b/lib/strategy_view.dart index 239a1c5d..46f33a4c 100644 --- a/lib/strategy_view.dart +++ b/lib/strategy_view.dart @@ -12,7 +12,6 @@ import 'package:icarus/interactive_map.dart'; import 'package:icarus/providers/agent_filter_provider.dart'; import 'package:icarus/providers/delete_menu_provider.dart'; import 'package:icarus/providers/interaction_state_provider.dart'; -import 'package:icarus/providers/library_rail_hover_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/services/unsaved_strategy_guard.dart'; import 'package:icarus/sidebar.dart'; @@ -30,6 +29,7 @@ import 'package:icarus/widgets/dialogs/create_lineup_dialog.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:url_launcher/url_launcher.dart'; +import 'package:icarus/widgets/window_chrome.dart'; import 'package:window_manager/window_manager.dart'; class StrategyView extends ConsumerStatefulWidget { @@ -179,7 +179,6 @@ class _StrategyViewState extends ConsumerState .updateFilterState(FilterState.all); ref.read(deleteMenuProvider.notifier).requestClose(); if (mounted) { - ref.read(suppressLibraryRailHoverProvider.notifier).state = true; Navigator.pop(context); } await ref.read(strategyProvider.notifier).clearCurrentStrategy(); @@ -219,69 +218,72 @@ class _StrategyViewState extends ConsumerState return Scaffold( body: Column( children: [ - Padding( - padding: - const EdgeInsets.only(left: 15, top: 15, bottom: 10, right: 15), - child: LayoutBuilder( - builder: (context, constraints) { - final showDiscordLabel = constraints.maxWidth >= 1000; - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - ShadIconButton.ghost( - foregroundColor: Colors.white, - onPressed: _leaveToLibrary, - icon: const Icon(Icons.home), - ), - const SizedBox(width: 5), - const StrategyEditBoundary( - disabledOpacity: 0.55, - child: MapSelector(), - ), - if (kIsWeb) - const Padding( - padding: EdgeInsets.symmetric(horizontal: 8.0), - child: DemoTag(), + EditorWindowHeader( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 15), + child: LayoutBuilder( + builder: (context, constraints) { + final showDiscordLabel = constraints.maxWidth >= 1000; + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + ShadIconButton.ghost( + foregroundColor: Colors.white, + onPressed: _leaveToLibrary, + icon: const Icon(Icons.home), ), - ], - ), - const StrategyQuickSwitcher(), - if (showDiscordLabel) - TextButton( - style: TextButton.styleFrom( - foregroundColor: Colors.white, - enabledMouseCursor: SystemMouseCursors.click, - ), - onPressed: () async { - await launchUrl(Settings.dicordLink); - }, - child: const Row( - children: [ - Text("Have any bugs? Join the Discord"), - SizedBox(width: 10), - Icon( - CustomIcons.discord, - color: Colors.white, + const SizedBox(width: 5), + const StrategyEditBoundary( + disabledOpacity: 0.55, + child: MapSelector(), + ), + if (kIsWeb) + const Padding( + padding: EdgeInsets.symmetric(horizontal: 8.0), + child: DemoTag(), ), - ], - ), - ) - else - Tooltip( - message: 'Have any bugs? Join the Discord', - child: ShadIconButton.ghost( - foregroundColor: Colors.white, + ], + ), + const StrategyQuickSwitcher(), + if (showDiscordLabel) + TextButton( + style: TextButton.styleFrom( + foregroundColor: Colors.white, + enabledMouseCursor: SystemMouseCursors.click, + ), onPressed: () async { await launchUrl(Settings.dicordLink); }, - icon: const Icon(CustomIcons.discord), + child: const Row( + children: [ + Text("Have any bugs? Join the Discord"), + SizedBox(width: 10), + Icon( + CustomIcons.discord, + color: Colors.white, + ), + ], + ), + ) + else + Tooltip( + message: 'Have any bugs? Join the Discord', + child: ShadIconButton.ghost( + foregroundColor: Colors.white, + onPressed: () async { + await launchUrl(Settings.dicordLink); + }, + icon: const Icon(CustomIcons.discord), + ), ), - ), - ], - ); - }, + ], + ); + }, + ), ), ), const Expanded( diff --git a/lib/widgets/current_path_bar.dart b/lib/widgets/current_path_bar.dart deleted file mode 100644 index 36dd83a4..00000000 --- a/lib/widgets/current_path_bar.dart +++ /dev/null @@ -1,166 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:icarus/const/settings.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/strategy_provider.dart'; -import 'package:icarus/strategy/strategy_page_models.dart'; -import 'package:icarus/widgets/folder_navigator.dart'; -import 'package:shadcn_ui/shadcn_ui.dart'; - -class CurrentPathBar extends ConsumerWidget { - const CurrentPathBar({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final workspace = ref.watch(libraryWorkspaceProvider); - final isCloud = workspace == LibraryWorkspace.cloud; - final cloudSection = ref.watch(cloudLibrarySectionProvider); - final currentFolderId = ref.watch(folderProvider); - final cloudFolders = isCloud - ? (ref.watch(cloudAllFoldersProvider).valueOrNull ?? const []) - .map((entry) => entry.folder) - .toList(growable: false) - : null; - final currentFolder = currentFolderId == null - ? null - : isCloud - ? cloudFolders - ?.where((folder) => folder.id == currentFolderId) - .firstOrNull - : ref - .read(folderProvider.notifier) - .findLocalFolderByID(currentFolderId); - final pathFolders = isCloud - ? _cloudPathFolders(currentFolder, cloudFolders) - : ref - .read(folderProvider.notifier) - .getFullPathIDs(currentFolder) - .map((id) => ref.read(folderProvider.notifier).findFolderByID(id)) - .whereType() - .toList(growable: false); - - return Container( - padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 8), - child: Row( - children: [ - Expanded( - child: ShadBreadcrumb( - lastItemTextColor: Settings.tacticalVioletTheme.foreground, - textStyle: ShadTheme.of(context).textTheme.lead, - children: [ - FolderTab( - folder: null, - isActive: currentFolder == null && - cloudSection != CloudLibrarySection.sharedWithMe, - ), - if (isCloud && cloudSection == CloudLibrarySection.sharedWithMe) - const _StaticBreadcrumbLink(label: 'Shared with Me'), - for (int i = 0; i < pathFolders.length; i++) - FolderTab( - folder: pathFolders[i], - isActive: i == pathFolders.length - 1, - ), - ], - ), - ), - ], - ), - ); - } - - List _cloudPathFolders(Folder? folder, List? cloudFolders) { - final pathFolders = []; - var current = folder; - while (current != null) { - pathFolders.insert(0, current); - final parentId = current.parentID; - if (parentId == null) { - current = null; - continue; - } - current = cloudFolders?.where((item) => item.id == parentId).firstOrNull; - } - return pathFolders; - } -} - -class FolderTab extends ConsumerWidget { - const FolderTab({ - super.key, - required this.folder, - this.isActive = false, - }); - - final Folder? folder; - final bool isActive; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final displayName = folder?.name ?? 'Home'; - - return ShadBreadcrumbLink( - textStyle: ShadTheme.of(context).textTheme.lead, - normalColor: isActive ? Settings.tacticalVioletTheme.foreground : null, - child: DragTarget( - builder: (context, candidateData, rejectedData) { - return Container( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Text(displayName), - ); - }, - onAcceptWithDetails: (details) async { - final item = details.data; - if (item is StrategyItem) { - await ref.read(strategyProvider.notifier).moveToFolder( - strategyID: item.strategyId, - parentID: folder?.id, - source: item.strategy == null - ? StrategySource.cloud - : StrategySource.local, - ); - } else if (item is FolderItem) { - await ref.read(folderProvider.notifier).moveToFolder( - folderID: item.folder.id, - parentID: folder?.id, - workspace: ref.read(libraryWorkspaceProvider), - ); - } - }, - ), - onPressed: () { - if (ref.read(libraryWorkspaceProvider) == LibraryWorkspace.cloud) { - final targetSection = folder == null - ? CloudLibrarySection.home - : ref.read(cloudLibrarySectionProvider); - ref.read(cloudLibrarySectionProvider.notifier).select(targetSection); - } - ref.read(folderProvider.notifier).updateID(folder?.id); - }, - ); - } -} - -class _StaticBreadcrumbLink extends StatelessWidget { - const _StaticBreadcrumbLink({required this.label}); - - final String label; - - @override - Widget build(BuildContext context) { - return ShadBreadcrumbLink( - textStyle: ShadTheme.of(context).textTheme.lead, - normalColor: Settings.tacticalVioletTheme.foreground, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Text(label), - ), - onPressed: () {}, - ); - } -} - -extension on Iterable { - Folder? get firstOrNull => isEmpty ? null : first; -} diff --git a/lib/widgets/dialogs/strategy/create_strategy_dialog.dart b/lib/widgets/dialogs/strategy/create_strategy_dialog.dart index 917d7dcb..3abf5a4a 100644 --- a/lib/widgets/dialogs/strategy/create_strategy_dialog.dart +++ b/lib/widgets/dialogs/strategy/create_strategy_dialog.dart @@ -28,7 +28,7 @@ class _NameStrategyDialogState extends ConsumerState { final isCloud = ref.watch(libraryWorkspaceProvider) == LibraryWorkspace.cloud; return ShadDialog( - title: Text(isCloud ? "Create Cloud Strategy" : "Create Strategy"), + title: const Text("Create Strategy"), actions: [ ShadButton( child: const Text("Create"), diff --git a/lib/widgets/folder_card.dart b/lib/widgets/folder_card.dart index 0d0f3da2..83a53f90 100644 --- a/lib/widgets/folder_card.dart +++ b/lib/widgets/folder_card.dart @@ -5,8 +5,8 @@ import 'package:icarus/const/folder_icons.dart'; 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/library_context_menu_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'; @@ -314,7 +314,7 @@ class _FolderCardState extends ConsumerState dragAnchorStrategy: pointerDragAnchorStrategy, onDragUpdate: (details) => _dragTiltController.addDelta(details.delta.dx), - data: FolderItem(_folder), + data: FolderItem(_folder, store: LibraryWorkspace.local), child: MouseRegion( onEnter: (_) { _isHovered = true; @@ -656,7 +656,8 @@ class _FolderCardState extends ConsumerState await showDialog( context: context, builder: (context) { - return FolderEditDialog(folder: _folder); + return FolderEditDialog( + folder: _folder, store: LibraryWorkspace.local); }, ); }, @@ -679,7 +680,7 @@ class _FolderCardState extends ConsumerState context: context, builder: (_) => DeleteFolderAlertDialog( folder: _folder, - workspace: ref.read(libraryWorkspaceProvider), + workspace: LibraryWorkspace.local, ), ); }, diff --git a/lib/widgets/folder_content.dart b/lib/widgets/folder_content.dart index 8431ce39..1fc2a0d3 100644 --- a/lib/widgets/folder_content.dart +++ b/lib/widgets/folder_content.dart @@ -16,10 +16,12 @@ import 'package:icarus/providers/pinned_items_provider.dart'; import 'package:icarus/providers/strategy_filter_provider.dart'; import 'package:icarus/strategy/strategy_models.dart'; import 'package:icarus/widgets/custom_search_field.dart'; +import 'package:icarus/providers/library_navigation_provider.dart'; +import 'package:icarus/widgets/library_breadcrumb.dart'; +import 'package:icarus/widgets/library_entries.dart'; import 'package:icarus/widgets/dialogs/auth/auth_dialog.dart'; import 'package:icarus/widgets/dialogs/share_links_dialog.dart'; import 'package:icarus/widgets/drop_insertion_indicator.dart'; -import 'package:icarus/widgets/folder_card.dart'; import 'package:icarus/widgets/folder_pill.dart'; import 'package:icarus/widgets/hover_dot_grid.dart'; import 'package:icarus/widgets/ica_drop_target.dart'; @@ -128,15 +130,15 @@ Set _folderAndDescendantIds(Folder root, Iterable allFolders) { } class FolderContent extends ConsumerWidget { - FolderContent({ + const FolderContent({ super.key, this.folder, required this.onCreateStrategy, }); + /// The open folder, or null at a tab's root. final Folder? folder; final VoidCallback onCreateStrategy; - final TextEditingController searchController = TextEditingController(); static final strategiesListenable = Provider>>((ref) { @@ -150,23 +152,46 @@ class FolderContent extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final workspace = ref.watch(libraryWorkspaceProvider); - if (workspace == LibraryWorkspace.community) { - return _buildCommunityPlaceholder(context, ref); + final tab = ref.watch(libraryTabProvider); + switch (tab) { + case LibraryTab.community: + return _buildCommunityPlaceholder(context, ref); + case LibraryTab.shared: + return _crossFade(_buildCloudBody(context, ref)); + case LibraryTab.library: + if (folder == null) { + return _crossFade(_buildLibraryRoot(context, ref)); + } + final store = ref.watch(libraryWorkspaceProvider); + if (store == LibraryWorkspace.cloud) { + return _crossFade(_buildCloudBody(context, ref)); + } + return _buildLocalFolder(context, ref); } + } - final isCloud = workspace == LibraryWorkspace.cloud; - if (isCloud) { - // Wrapped in a switcher so skeleton -> content (and error transitions) - // cross-fade instead of hard-snapping. - return AnimatedSwitcher( - duration: const Duration(milliseconds: 200), - switchInCurve: Curves.easeOutCubic, - switchOutCurve: Curves.easeOutCubic, - child: _buildCloudBody(context, ref), - ); - } + /// Skeleton -> content (and error transitions) cross-fade instead of + /// hard-snapping. + Widget _crossFade(Widget child) { + return AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeOutCubic, + child: child, + ); + } + /// Reads the local library and hands the visible rows to [builder]. Used + /// both inside a local folder and for the local half of the My Library root. + Widget _withLocalStore( + BuildContext context, + WidgetRef ref, { + required Widget Function( + List folders, + List strategies, + ) builder, + }) { + final cloudAvailable = ref.watch(isCloudWorkspaceAvailableProvider); final strategiesBoxListenable = ref.watch(strategiesListenable); final foldersBoxListenable = ref.watch(foldersListenable); return ValueListenableBuilder>( @@ -178,47 +203,137 @@ class FolderContent extends ConsumerWidget { final allFolders = folderBox.values.toList(); final allStrategies = strategyBox.values.toList(); final existingFolderIds = allFolders.map((item) => item.id).toSet(); - final folders = allFolders - .where( - (item) => folderBelongsToVisibleParent( + final folders = [ + for (final item in allFolders) + if (folderBelongsToVisibleParent( + folder: item, + currentFolderId: folder?.id, + )) + LibraryFolderRow( folder: item, - currentFolderId: folder?.id, + store: LibraryWorkspace.local, + lastUpdated: folderLastUpdated( + folder: item, + allFolders: allFolders, + allStrategies: allStrategies, + ), ), - ) - .toList(); - final strategies = allStrategies - .where( - (item) => strategyBelongsToVisibleFolder( - strategy: item, - currentFolderId: folder?.id, - existingFolderIds: existingFolderIds, + ]; + final strategies = [ + for (final item in allStrategies) + if (strategyBelongsToVisibleFolder( + strategy: item, + currentFolderId: folder?.id, + existingFolderIds: existingFolderIds, + )) + LibraryStrategyRow.local( + item, + showDeviceBadge: cloudAvailable, ), - ) - .toList(); - return _buildScaffold( - context, - ref, - folders: _filterFolders( - ref, - folders, - allFolders: allFolders, - allStrategies: allStrategies, - ), - localStrategies: _filterLocalStrategies(ref, strategies), - allLocalFolders: allFolders, - allLocalStrategies: allStrategies, - cloudStrategies: const [], - isCloud: false, - emptyStateTitle: 'No strategies available', - emptyStateSubtitle: - 'Create a new strategy or drop strategies, folders, or .zip archives', - ); + ]; + return builder(folders, strategies); }, ); }, ); } + Widget _buildLocalFolder(BuildContext context, WidgetRef ref) { + return _withLocalStore( + context, + ref, + builder: (folders, strategies) => _buildScaffold( + context, + ref, + folders: _filterFolders(ref, folders), + strategies: _filterStrategies(ref, strategies), + acceptsIcaDrops: true, + emptyStateTitle: 'No strategies in this folder', + emptyStateSubtitle: + 'Create a new strategy or drop strategies, folders, or .zip archives', + ), + ); + } + + /// My Library's root: everything on this computer and everything in the + /// cloud, side by side. Inside a folder the view narrows to that folder's + /// store. + Widget _buildLibraryRoot(BuildContext context, WidgetRef ref) { + final cloudAvailable = ref.watch(isCloudWorkspaceAvailableProvider); + final foldersAsync = cloudAvailable ? ref.watch(cloudFoldersProvider) : null; + final strategiesAsync = + cloudAvailable ? ref.watch(cloudStrategiesProvider) : null; + // Only the very first fetch shows the skeleton; dependency changes keep + // the previous value. + final isInitialLoading = foldersAsync != null && + strategiesAsync != null && + ((foldersAsync.isLoading && !foldersAsync.hasValue) || + (strategiesAsync.isLoading && !strategiesAsync.hasValue)); + if (isInitialLoading) { + return const _LibraryLoadingSkeleton(key: ValueKey('cloud-loading')); + } + final cloudFailed = + (foldersAsync?.hasError ?? false) || (strategiesAsync?.hasError ?? false); + final cloudFolders = [ + for (final entry in foldersAsync?.valueOrNull ?? const []) + LibraryFolderRow( + folder: entry.folder, + store: LibraryWorkspace.cloud, + lastUpdated: entry.folder.dateCreated, + ), + ]; + final cloudStrategies = [ + for (final entry + in strategiesAsync?.valueOrNull ?? const []) + LibraryStrategyRow.cloud(entry), + ]; + + return KeyedSubtree( + key: const ValueKey('library-root'), + child: _withLocalStore( + context, + ref, + builder: (localFolders, localStrategies) => _buildScaffold( + context, + ref, + folders: _filterFolders( + ref, + mergeLibraryFolders(local: localFolders, cloud: cloudFolders), + ), + strategies: _filterStrategies( + ref, + mergeLibraryStrategies( + local: localStrategies, + cloud: cloudStrategies, + ), + ), + acceptsIcaDrops: true, + banner: cloudFailed ? _CloudErrorBanner(onRetry: () => _retryCloud(ref)) : null, + emptyStateKey: const ValueKey('library-empty-state'), + emptyStateIcon: Icons.folder_outlined, + emptyStateTitle: 'Your library is empty', + emptyStateSubtitle: cloudAvailable + ? 'Create your first strategy to keep it available across your ' + 'Icarus clients.' + : 'Create a new strategy or drop strategies, folders, or .zip ' + 'archives here.', + emptyStateAction: ShadButton( + key: const ValueKey('library-empty-create-strategy'), + onPressed: onCreateStrategy, + leading: const Icon(Icons.add), + child: const Text('Create Strategy'), + ), + ), + ), + ); + } + + void _retryCloud(WidgetRef ref) { + ref.invalidate(cloudFolderTreeProvider); + ref.invalidate(cloudStrategiesProvider); + } + + /// A cloud folder, or the Shared tab. Widget _buildCloudBody(BuildContext context, WidgetRef ref) { final cloudSection = ref.watch(cloudLibrarySectionProvider); final cloudAvailable = ref.watch(isCloudWorkspaceAvailableProvider); @@ -236,8 +351,6 @@ class FolderContent extends ConsumerWidget { child: _buildCloudErrorState(context, ref), ); } - // Only the very first fetch shows the skeleton; dependency changes keep - // the previous value, so navigating folders doesn't flash it. final isInitialLoading = (foldersAsync.isLoading && !foldersAsync.hasValue) || (strategiesAsync.isLoading && !strategiesAsync.hasValue); @@ -246,10 +359,19 @@ class FolderContent extends ConsumerWidget { key: ValueKey('cloud-loading'), ); } - final folders = (foldersAsync.valueOrNull ?? const []) - .map((entry) => entry.folder) - .toList(growable: false); - final strategies = strategiesAsync.valueOrNull ?? const []; + final folders = [ + for (final entry in foldersAsync.valueOrNull ?? const []) + LibraryFolderRow( + folder: entry.folder, + store: LibraryWorkspace.cloud, + lastUpdated: entry.folder.dateCreated, + ), + ]; + final strategies = [ + for (final entry + in strategiesAsync.valueOrNull ?? const []) + LibraryStrategyRow.cloud(entry), + ]; final isSharedWithMe = cloudSection == CloudLibrarySection.sharedWithMe; return KeyedSubtree( key: const ValueKey('cloud-content'), @@ -257,145 +379,91 @@ class FolderContent extends ConsumerWidget { context, ref, folders: _filterFolders(ref, folders), - localStrategies: const [], - cloudStrategies: _filterCloudStrategies(ref, strategies), - isCloud: true, - emptyStateKey: ValueKey( - isSharedWithMe ? 'shared-empty-state' : 'cloud-empty-state', - ), - emptyStateIcon: - isSharedWithMe ? Icons.people_outline : Icons.cloud_outlined, - emptyStateTitle: isSharedWithMe + strategies: _filterStrategies(ref, strategies), + acceptsIcaDrops: false, + emptyStateKey: isSharedWithMe && folder == null + ? const ValueKey('shared-empty-state') + : null, + emptyStateIcon: isSharedWithMe && folder == null + ? Icons.people_outline + : null, + emptyStateTitle: isSharedWithMe && folder == null ? 'Nothing shared with you yet' - : 'Your cloud library is empty', - emptyStateSubtitle: isSharedWithMe + : 'No strategies in this folder', + emptyStateSubtitle: isSharedWithMe && folder == null ? '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.', + : isSharedWithMe + ? 'Strategies shared into this folder will show up here.' + : 'Create a new strategy to fill it.', 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'), - ) + ? (folder == null + ? ShadButton( + key: const ValueKey('shared-empty-add-item'), + onPressed: () => showAddSharedItemDialog(context), + leading: const Icon(LucideIcons.link), + child: const Text('Add by Link or Code'), + ) + : null) : ShadButton( key: const ValueKey('cloud-empty-create-strategy'), onPressed: onCreateStrategy, leading: const Icon(Icons.add), - child: const Text('Create Cloud Strategy'), + child: const Text('Create Strategy'), ), ), ); } - List _filterFolders( + List _filterFolders( WidgetRef ref, - List folders, { - List allFolders = const [], - List allStrategies = const [], - }) { + List folders, + ) { final search = ref.watch(strategySearchQueryProvider).trim().toLowerCase(); final filter = ref.watch(strategyFilterProvider); - final filtered = [...folders]; - if (search.isNotEmpty) { - filtered.retainWhere( - (folder) => folder.name.toLowerCase().contains(search), - ); - } - final direction = filter.sortOrder == SortOrder.ascending ? 1 : -1; - filtered.sort( - (a, b) => - direction * - (allFolders.isEmpty - ? a.dateCreated.compareTo(b.dateCreated) - : compareFoldersForSort( - a: a, - b: b, - sortBy: filter.sortBy, - allFolders: allFolders, - allStrategies: allStrategies, - )), - ); + final filtered = search.isEmpty + ? folders + : folders + .where((row) => row.folder.name.toLowerCase().contains(search)) + .toList(); + final sorted = sortLibraryFolders(filtered, filter); final pinned = ref.watch(pinnedItemsProvider); return search.isEmpty && pinned.isNotEmpty - ? sortPinnedItemsFirst(filtered, pinned, (item) => item.id) - : filtered; + ? sortPinnedItemsFirst(sorted, pinned, (row) => row.id) + : sorted; } - List _filterLocalStrategies( + List _filterStrategies( WidgetRef ref, - List strategies, + List strategies, ) { final search = ref.watch(strategySearchQueryProvider).trim().toLowerCase(); final filter = ref.watch(strategyFilterProvider); - final filtered = [...strategies]; - if (search.isNotEmpty) { - filtered.retainWhere( - (strategy) => strategy.name.toLowerCase().contains(search), - ); - } - - Comparator comparator = switch (filter.sortBy) { - SortBy.alphabetical => (a, b) => - a.name.toLowerCase().compareTo(b.name.toLowerCase()), - SortBy.dateCreated => (a, b) => a.createdAt.compareTo(b.createdAt), - SortBy.dateUpdated => (a, b) => a.lastEdited.compareTo(b.lastEdited), - }; - - final direction = filter.sortOrder == SortOrder.ascending ? 1 : -1; - filtered.sort((a, b) => direction * comparator(a, b)); + final filtered = search.isEmpty + ? strategies + : strategies + .where((row) => row.name.toLowerCase().contains(search)) + .toList(); + final sorted = sortLibraryStrategies(filtered, filter); final pinned = ref.watch(pinnedItemsProvider); return search.isEmpty && pinned.isNotEmpty - ? sortPinnedItemsFirst(filtered, pinned, (item) => item.id) - : filtered; - } - - List _filterCloudStrategies( - WidgetRef ref, - List strategies, - ) { - final search = ref.watch(strategySearchQueryProvider).trim().toLowerCase(); - final filter = ref.watch(strategyFilterProvider); - final filtered = [...strategies]; - if (search.isNotEmpty) { - filtered.retainWhere( - (entry) => entry.strategy.name.toLowerCase().contains(search), - ); - } - - Comparator comparator = switch (filter.sortBy) { - SortBy.alphabetical => (a, b) => a.strategy.name - .toLowerCase() - .compareTo(b.strategy.name.toLowerCase()), - SortBy.dateCreated => (a, b) => - a.strategy.createdAt.compareTo(b.strategy.createdAt), - SortBy.dateUpdated => (a, b) => - a.strategy.lastEdited.compareTo(b.strategy.lastEdited), - }; - - final direction = filter.sortOrder == SortOrder.ascending ? 1 : -1; - filtered.sort((a, b) => direction * comparator(a, b)); - return filtered; + ? sortPinnedItemsFirst(sorted, pinned, (row) => row.id) + : sorted; } Widget _buildScaffold( BuildContext context, WidgetRef ref, { - required List folders, - required List localStrategies, - required List cloudStrategies, - List allLocalFolders = const [], - List allLocalStrategies = const [], - required bool isCloud, + required List folders, + required List strategies, + required bool acceptsIcaDrops, + Widget? banner, Key? emptyStateKey, IconData? emptyStateIcon, required String emptyStateTitle, required String emptyStateSubtitle, Widget? emptyStateAction, }) { - final hasStrategies = - localStrategies.isNotEmpty || cloudStrategies.isNotEmpty; + final hasStrategies = strategies.isNotEmpty; final Widget emptyState = Center( key: emptyStateKey, child: ConstrainedBox( @@ -435,7 +503,7 @@ class FolderContent extends ConsumerWidget { final Widget content = LayoutBuilder( builder: (context, constraints) { const double minTileWidth = 250; - final spacing = isCloud ? 20.0 : strategyTileGridSpacing; + const double spacing = strategyTileGridSpacing; const double padding = 32; final crossAxisCount = math.max( 1, @@ -449,71 +517,40 @@ class FolderContent extends ConsumerWidget { if (folders.isNotEmpty) SliverToBoxAdapter( child: Padding( - padding: EdgeInsets.fromLTRB( - isCloud ? 16 : 16 - folderCardGutterOutset, - 16, - isCloud ? 16 : 16 - folderCardGutterOutset, - 8, - ), + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), child: Wrap( - spacing: isCloud ? 10 : 0, - runSpacing: isCloud ? 10 : 14, - children: folders - .map( - (folder) => isCloud - ? FolderPill(folder: folder) - : FolderCard( - key: ValueKey(folder.id), - data: FolderCardViewData( - folder: folder, - strategies: strategiesInFolderTree( - folder: folder, - allFolders: allLocalFolders, - allStrategies: allLocalStrategies, - ), - folderCount: allLocalFolders - .where( - (item) => item.parentID == folder.id, - ) - .length, - ), - ), - ) - .toList(), + spacing: 10, + runSpacing: 10, + children: [ + for (final row in folders) + FolderPill( + key: ValueKey(row.id), + folder: row.folder, + store: row.store, + ), + ], ), ), ), if (hasStrategies) SliverPadding( - padding: EdgeInsets.all( - isCloud ? 16 : 16 - strategyTileGutterOutset, - ), + padding: const EdgeInsets.all(16 - strategyTileGutterOutset), sliver: SliverGrid( gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: crossAxisCount, - mainAxisExtent: - isCloud ? 250 : strategyTileGridMainAxisExtent, - crossAxisSpacing: isCloud ? 20 : 0, - mainAxisSpacing: isCloud ? 20 : 0, + mainAxisExtent: strategyTileGridMainAxisExtent, ), delegate: SliverChildListDelegate.fixed( [ - ...localStrategies.map( - (strategy) => StrategyTile.local( - strategyData: strategy, - ), - ), - ...cloudStrategies.map((strategy) { - final caps = - StrategyCapabilities.fromCloudRole(strategy.role); - return StrategyTile.cloud( - cloudStrategy: strategy, - canRename: caps.canRenameStrategy, - canDuplicate: caps.canDuplicateStrategy, - canDelete: caps.canDeleteStrategy, - canMove: caps.canMoveStrategy, - ); - }), + for (final row in strategies) + if (row.local case final local?) + StrategyTile.local( + key: ValueKey(row.id), + strategyData: local, + showDeviceBadge: row.showDeviceBadge, + ) + else + _cloudTile(row.cloud!), ], ), ), @@ -537,11 +574,10 @@ class FolderContent extends ConsumerWidget { ); }, ); - final wrappedContent = isCloud - ? content - : IcaDropTarget( - child: DropInsertionIndicatorScope(child: content), - ); + final wrappedContent = acceptsIcaDrops + ? IcaDropTarget(child: DropInsertionIndicatorScope(child: content)) + : DropInsertionIndicatorScope(child: content); + final currentFolder = folder; return Listener( behavior: HitTestBehavior.translucent, @@ -557,47 +593,12 @@ class FolderContent extends ConsumerWidget { Positioned.fill( child: Column( children: [ - Padding( - padding: const EdgeInsets.only(top: 4.0, left: 16, right: 16), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - spacing: 8, - children: [ - _SortSelect( - currentValue: - ref.watch(strategyFilterProvider).sortBy, - labels: StrategyFilterProvider.sortByLabels, - values: SortBy.values, - onChanged: (value) => ref - .read(strategyFilterProvider.notifier) - .setSortBy(value), - ), - _SortSelect( - currentValue: - ref.watch(strategyFilterProvider).sortOrder, - labels: StrategyFilterProvider.sortOrderLabels, - values: SortOrder.values, - onChanged: (value) => ref - .read(strategyFilterProvider.notifier) - .setSortOrder(value), - ), - ], - ), - SizedBox( - height: 40, - child: SearchTextField( - controller: searchController, - collapsedWidth: 40, - expandedWidth: 250, - compact: true, - onChanged: (value) {}, - ), - ), - ], + if (currentFolder != null) + Padding( + padding: const EdgeInsets.fromLTRB(12, 10, 16, 0), + child: LibraryBreadcrumb(folder: currentFolder), ), - ), + if (banner != null) banner, Expanded( child: AnimatedSwitcher( duration: const Duration(milliseconds: 200), @@ -606,9 +607,9 @@ class FolderContent extends ConsumerWidget { child: (folders.isEmpty && !hasStrategies) ? KeyedSubtree( key: const ValueKey('library-empty'), - child: isCloud - ? emptyState - : IcaDropTarget(child: emptyState), + child: acceptsIcaDrops + ? IcaDropTarget(child: emptyState) + : emptyState, ) : KeyedSubtree( key: const ValueKey('library-content'), @@ -624,13 +625,25 @@ class FolderContent extends ConsumerWidget { ); } + Widget _cloudTile(CloudStrategyEntry entry) { + final caps = StrategyCapabilities.fromCloudRole(entry.role); + return StrategyTile.cloud( + key: ValueKey(entry.strategy.id), + cloudStrategy: entry, + canRename: caps.canRenameStrategy, + canDuplicate: caps.canDuplicateStrategy, + canDelete: caps.canDeleteStrategy, + canMove: caps.canMoveStrategy, + ); + } + Widget _buildCloudUnavailableState(BuildContext context, WidgetRef ref) { return _LibraryMessageState( icon: Icons.cloud_off_outlined, iconColor: Settings.tacticalVioletTheme.mutedForeground, - title: 'Cloud workspace unavailable', - subtitle: 'Sign in again to reach your online strategies, or switch ' - 'back to Local to keep working.', + title: 'Cloud unavailable', + subtitle: 'Sign in again to reach your online strategies, or go back ' + 'to your library to keep working.', actions: [ ShadButton( onPressed: () { @@ -642,12 +655,8 @@ class FolderContent extends ConsumerWidget { child: const Text('Log In'), ), ShadButton.secondary( - onPressed: () { - ref - .read(libraryWorkspaceProvider.notifier) - .select(LibraryWorkspace.local); - }, - child: const Text('Back to Local'), + onPressed: ref.read(libraryNavigationProvider).showLibrary, + child: const Text('Back to My Library'), ), ], ); @@ -662,19 +671,12 @@ class FolderContent extends ConsumerWidget { actions: [ ShadButton( leading: const Icon(LucideIcons.refreshCw, size: 14), - onPressed: () { - ref.invalidate(cloudFoldersProvider); - ref.invalidate(cloudStrategiesProvider); - }, + onPressed: () => _retryCloud(ref), child: const Text('Retry'), ), ShadButton.secondary( - onPressed: () { - ref - .read(libraryWorkspaceProvider.notifier) - .select(LibraryWorkspace.local); - }, - child: const Text('Back to Local'), + onPressed: ref.read(libraryNavigationProvider).showLibrary, + child: const Text('Back to My Library'), ), ], ); @@ -689,18 +691,60 @@ class FolderContent extends ConsumerWidget { 'This space is reserved for public lineups, team executes, and discoverable strategy packs.', actions: [ ShadButton.secondary( - onPressed: () { - ref - .read(libraryWorkspaceProvider.notifier) - .select(LibraryWorkspace.local); - }, - child: const Text('Back to Local'), + onPressed: ref.read(libraryNavigationProvider).showLibrary, + child: const Text('Back to My Library'), ), ], ); } } +/// Shown above the My Library root when the cloud half failed to load. The +/// local half stays usable underneath. +class _CloudErrorBanner extends StatelessWidget { + const _CloudErrorBanner({required this.onRetry}); + + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: Container( + key: const ValueKey('cloud-error-banner'), + padding: const EdgeInsets.fromLTRB(12, 6, 6, 6), + decoration: BoxDecoration( + color: Settings.tacticalVioletTheme.card, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Settings.tacticalVioletTheme.border), + ), + child: Row( + children: [ + Icon( + Icons.cloud_off_outlined, + size: 16, + color: Settings.tacticalVioletTheme.destructive, + ), + const SizedBox(width: 10), + const Expanded( + child: Text( + "Couldn't load your cloud library. Showing what's on this " + 'computer.', + ), + ), + ShadButton.ghost( + height: 28, + leading: const Icon(LucideIcons.refreshCw, size: 14), + onPressed: onRetry, + child: const Text('Retry'), + ), + ], + ), + ), + ); + } +} + /// Shared full-pane message layout (dot-grid backdrop, icon, title, subtitle, /// action row) used by the community placeholder and cloud /// unavailable/error states so they carry the same visual weight. @@ -893,41 +937,3 @@ class _LibraryLoadingSkeletonState extends State<_LibraryLoadingSkeleton> ); } } - -class _SortSelect extends StatelessWidget { - const _SortSelect({ - required this.currentValue, - required this.labels, - required this.values, - required this.onChanged, - }); - - final T currentValue; - final Map labels; - final Iterable values; - final ValueChanged onChanged; - - @override - Widget build(BuildContext context) { - return ShadSelect( - decoration: ShadDecoration( - color: Settings.tacticalVioletTheme.card, - shadows: const [Settings.cardForegroundBackdrop], - ), - initialValue: currentValue, - selectedOptionBuilder: (context, value) => Text(labels[value]!), - options: [ - for (final value in values) - ShadOption( - value: value, - child: Text(labels[value]!), - ), - ], - onChanged: (value) { - if (value != null) { - onChanged(value); - } - }, - ); - } -} diff --git a/lib/widgets/folder_edit_dialog.dart b/lib/widgets/folder_edit_dialog.dart index 2d6cd998..ed8a1f42 100644 --- a/lib/widgets/folder_edit_dialog.dart +++ b/lib/widgets/folder_edit_dialog.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/folder_icons.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/providers/folder_provider.dart'; +import 'package:icarus/providers/library_workspace_provider.dart'; import 'package:icarus/services/app_error_reporter.dart'; import 'package:icarus/services/cloud_library_action.dart'; import 'package:icarus/widgets/better_color_picker.dart'; @@ -25,8 +26,12 @@ class FolderEditDialog extends ConsumerStatefulWidget { const FolderEditDialog({ super.key, this.folder, + this.store, }); final Folder? folder; + + /// The store to write to. Defaults to the active workspace. + final LibraryWorkspace? store; @override ConsumerState createState() => _FolderEditDialogState(); @@ -61,6 +66,7 @@ class _FolderEditDialogState extends ConsumerState { newIconId: _selectedIconId, newColor: _selectedColor, newCustomColor: _customColor, + workspace: widget.store, ); } else { await ref.read(folderProvider.notifier).createFolder( @@ -68,6 +74,7 @@ class _FolderEditDialogState extends ConsumerState { iconId: _selectedIconId, color: _selectedColor, customColor: _customColor, + workspace: widget.store, ); result = CloudLibraryActionResult.succeeded; } diff --git a/lib/widgets/folder_navigator.dart b/lib/widgets/folder_navigator.dart index 00665472..303c00c7 100644 --- a/lib/widgets/folder_navigator.dart +++ b/lib/widgets/folder_navigator.dart @@ -10,29 +10,23 @@ import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/const/update_checker.dart'; import 'package:icarus/main.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_rail_hover_provider.dart'; +import 'package:icarus/providers/library_navigation_provider.dart'; import 'package:icarus/providers/library_workspace_provider.dart'; import 'package:icarus/strategy/strategy_import_export.dart'; import 'package:icarus/strategy/strategy_models.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; import 'package:icarus/providers/update_status_provider.dart'; import 'package:icarus/services/app_error_reporter.dart'; -import 'package:icarus/services/guarded_sign_out.dart'; import 'package:icarus/services/windows_desktop_update_controller.dart'; import 'package:icarus/strategy_view.dart'; -import 'package:icarus/widgets/current_path_bar.dart'; import 'package:icarus/widgets/desktop_update_dialog.dart'; -import 'package:icarus/widgets/demo_tag.dart'; -import 'package:icarus/widgets/dialogs/auth/auth_dialog.dart'; -import 'package:icarus/widgets/dialogs/share_links_dialog.dart'; import 'package:icarus/widgets/dialogs/strategy/create_strategy_dialog.dart'; import 'package:icarus/widgets/dialogs/web_view_dialog.dart'; -import 'package:icarus/widgets/account_avatar.dart'; -import 'package:icarus/widgets/cloud_outbox_summary_banner.dart'; import 'package:icarus/widgets/folder_content.dart'; +import 'package:icarus/widgets/cloud_outbox_summary_banner.dart'; +import 'package:icarus/widgets/library_title_strip.dart'; import 'package:icarus/widgets/folder_edit_dialog.dart'; import 'package:icarus/widgets/ica_drop_target.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; @@ -49,18 +43,14 @@ class _FolderNavigatorState extends ConsumerState { bool _warnedOnce = false; bool _hasPromptedUpdateDialog = false; WindowsDesktopUpdateController? _desktopUpdaterController; - final GlobalKey _importExportButtonKey = GlobalKey(); final ShadContextMenuController _backgroundMenuController = ShadContextMenuController(); - final ShadPopoverController _importExportPopoverController = - ShadPopoverController(); bool get _isWindowsDesktop => !kIsWeb && defaultTargetPlatform == TargetPlatform.windows; @override void dispose() { - _importExportPopoverController.dispose(); _backgroundMenuController.dispose(); _desktopUpdaterController?.dispose(); super.dispose(); @@ -93,6 +83,8 @@ class _FolderNavigatorState extends ConsumerState { // Show the demo warning only once after the first frame on web. WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _followCloudAvailability(ref.read(isCloudWorkspaceAvailableProvider)); if (!_warnedOnce) { _warnedOnce = true; @@ -122,10 +114,6 @@ class _FolderNavigatorState extends ConsumerState { ); } - void _toggleImportExportPopover() { - _importExportPopoverController.toggle(); - } - Future handleImportIca() async { if (kIsWeb) { _showDesktopOnlyToast(); @@ -205,8 +193,22 @@ class _FolderNavigatorState extends ConsumerState { } } + /// Signed in, My Library writes to the cloud. Auth restores after the + /// first frame, so re-land on the tab's root once the cloud is reachable. + void _followCloudAvailability(bool available) { + if (!available) return; + if (ref.read(libraryTabProvider) != LibraryTab.library) return; + if (ref.read(libraryWorkspaceProvider) == LibraryWorkspace.cloud) return; + if (ref.read(folderProvider) != null) return; + ref.read(libraryNavigationProvider).showLibrary(); + } + @override Widget build(BuildContext context) { + ref.listen( + isCloudWorkspaceAvailableProvider, + (_, available) => _followCloudAvailability(available), + ); ref.listen>(appUpdateStatusProvider, (_, next) { next.whenData((result) { @@ -242,12 +244,9 @@ class _FolderNavigatorState extends ConsumerState { final double height = MediaQuery.sizeOf(context).height - 90; final Size playAreaSize = Size(height * (16 / 9), height); CoordinateSystem(playAreaSize: playAreaSize); + final tab = ref.watch(libraryTabProvider); final workspace = ref.watch(libraryWorkspaceProvider); final isCloudWorkspace = workspace == LibraryWorkspace.cloud; - final isCommunityWorkspace = workspace == LibraryWorkspace.community; - final cloudSection = ref.watch(cloudLibrarySectionProvider); - final isSharedWithMe = - isCloudWorkspace && cloudSection == CloudLibrarySection.sharedWithMe; final currentFolderId = ref.watch(folderProvider); final currentFolder = currentFolderId != null ? isCloudWorkspace @@ -259,6 +258,8 @@ class _FolderNavigatorState extends ConsumerState { .read(folderProvider.notifier) .findLocalFolderByID(currentFolderId) : null; + final canCreate = tab == LibraryTab.library; + Future navigateToLocalStrategy( BuildContext context, String strategyId, { @@ -296,7 +297,7 @@ class _FolderNavigatorState extends ConsumerState { if (strategyId != null) { if (!context.mounted) return; - if (isCloudWorkspace) { + if (ref.read(libraryWorkspaceProvider) == LibraryWorkspace.cloud) { await Navigator.push( context, StrategyView.route(), @@ -307,192 +308,50 @@ class _FolderNavigatorState extends ConsumerState { } } - const double railReservedWidth = 64; - return Stack( children: [ Scaffold( - appBar: AppBar( - title: const Padding( - padding: EdgeInsets.only(left: railReservedWidth), - child: CurrentPathBar(), - ), - toolbarHeight: 70, - actionsPadding: const EdgeInsets.only(right: 24), - - actions: [ - if (kIsWeb) - const Padding( - padding: EdgeInsets.symmetric(horizontal: 8.0), - child: DemoTag(), - ), - Row( - spacing: 15, - children: [ - if (isSharedWithMe) - ShadButton( - key: const ValueKey('cloud-add-shared-item'), - onPressed: () => showAddSharedItemDialog(context), - leading: const Icon(LucideIcons.link), - child: const Text('Add by Link or Code'), - ) - else ...[ - ShadPopover( - controller: _importExportPopoverController, - padding: const EdgeInsets.all(8), - anchor: const ShadAnchor( - offset: Offset(0, 8), - childAlignment: Alignment.topLeft, - overlayAlignment: Alignment.bottomLeft, - ), - popover: (context) { - return SizedBox( - width: 178, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - ShadButton.ghost( - onPressed: handleImportIca, - mainAxisAlignment: MainAxisAlignment.start, - leading: const Icon( - Icons.file_download, - ), - child: Text( - 'Import .ica', - style: TextStyle( - color: - Settings.tacticalVioletTheme.foreground, - ), - ), - ), - ShadButton.ghost( - onPressed: handleImportBackup, - mainAxisAlignment: MainAxisAlignment.start, - leading: const Icon( - Icons.archive_outlined, - ), - child: Text( - 'Import Backup', - style: TextStyle( - color: - Settings.tacticalVioletTheme.foreground, - ), - ), - ), - ShadButton.ghost( - onPressed: handleExportLibrary, - mainAxisAlignment: MainAxisAlignment.start, - leading: const Icon( - Icons.backup_outlined, - ), - child: Text( - 'Export Library', - style: TextStyle( - color: - Settings.tacticalVioletTheme.foreground, - ), - ), - ), - ], + body: Column( + children: [ + LibraryTitleStrip( + onCreateStrategy: showCreateDialog, + onCreateFolder: showCreateFolderDialog, + onImportIca: handleImportIca, + onImportBackup: handleImportBackup, + onExportLibrary: handleExportLibrary, + ), + if (tab != LibraryTab.community) const CloudOutboxSummaryBanner(), + Expanded( + child: ShadContextMenuRegion( + controller: _backgroundMenuController, + items: !canCreate + ? const [] + : [ + ShadContextMenuItem( + leading: + const Icon(Icons.create_new_folder_outlined), + onPressed: showCreateFolderDialog, + child: const Text('Create Folder'), ), - ); - }, - child: ShadButton.secondary( - key: _importExportButtonKey, - onPressed: isCloudWorkspace || isCommunityWorkspace - ? null - : _toggleImportExportPopover, - leading: const Icon(Icons.import_export), - trailing: const Icon(Icons.keyboard_arrow_down), - child: const Text('Import / Export'), - ), - ), - ShadButton.secondary( - key: ValueKey( - isCloudWorkspace - ? 'cloud-add-folder' - : 'local-add-folder', - ), - leading: const Icon(LucideIcons.folderPlus), - onPressed: isCommunityWorkspace - ? null - : () async { - await showDialog( - context: context, - builder: (context) { - return const FolderEditDialog(); - }, - ); - }, - child: const Text('Add Folder'), - ), - ShadButton( - key: ValueKey( - isCloudWorkspace - ? 'cloud-create-strategy' - : 'local-create-strategy', - ), - onPressed: isCommunityWorkspace ? null : showCreateDialog, - leading: const Icon(Icons.add), - child: Text( - isCloudWorkspace - ? 'Create Cloud Strategy' - : 'Create Strategy', - ), - ), - ], - ], - ) - ], - // ... your existing actions - ), - body: Padding( - padding: const EdgeInsets.only(left: railReservedWidth), - child: ShadContextMenuRegion( - controller: _backgroundMenuController, - items: isCommunityWorkspace || isSharedWithMe - ? const [] - : [ - ShadContextMenuItem( - leading: const Icon(Icons.create_new_folder_outlined), - onPressed: showCreateFolderDialog, - child: const Text('Create Folder'), - ), - ShadContextMenuItem( - leading: const Icon(Icons.note_add_outlined), - onPressed: showCreateDialog, - child: const Text('Create Strategy'), - ), - ], - child: Column( - children: [ - if (isCloudWorkspace) const CloudOutboxSummaryBanner(), - Expanded( - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 220), - switchInCurve: Curves.easeOutCubic, - switchOutCurve: Curves.easeOutCubic, - child: KeyedSubtree( - key: ValueKey('$workspace/$cloudSection'), - child: FolderContent( - folder: currentFolder, - onCreateStrategy: showCreateDialog, - ), - ), - ), + ShadContextMenuItem( + leading: const Icon(Icons.note_add_outlined), + onPressed: showCreateDialog, + child: const Text('Create Strategy'), + ), + ], + // Tabs are navigation, so the destination appears on the + // next frame. FolderContent owns loading transitions after + // the selected destination is already visible. + child: FolderContent( + key: ValueKey('library-tab-body-${tab.name}'), + folder: currentFolder, + onCreateStrategy: showCreateDialog, ), - ], + ), ), - ), + ], ), ), - const Positioned( - left: 0, - top: 0, - bottom: 0, - child: LibraryNavigationRail(), - ), if (_desktopUpdaterController != null) DesktopUpdateDialogListener( controller: _desktopUpdaterController!, @@ -502,12 +361,18 @@ class _FolderNavigatorState extends ConsumerState { } } -sealed class GridItem {} +/// Something the user can drag around the library grid. +sealed class GridItem { + /// The store the item lives in. Drops across stores are refused. + LibraryWorkspace get store; +} class FolderItem extends GridItem { final Folder folder; + @override + final LibraryWorkspace store; - FolderItem(this.folder); + FolderItem(this.folder, {required this.store}); } class StrategyItem extends GridItem { @@ -517,555 +382,8 @@ class StrategyItem extends GridItem { StrategyItem.local(this.strategy) : strategyId = strategy!.id; StrategyItem.cloud(this.strategyId) : strategy = null; -} - -class LibraryNavigationRail extends ConsumerStatefulWidget { - const LibraryNavigationRail({super.key}); - - @override - ConsumerState createState() => - _LibraryNavigationRailState(); -} - -class _LibraryNavigationRailState extends ConsumerState { - static const _closeDelay = Duration(milliseconds: 120); - static const _detailsDelay = Duration(milliseconds: 190); - static const _routeArrivalHoverDelay = Duration(seconds: 2); - - bool _expanded = false; - bool _showExpandedContent = false; - Timer? _closeTimer; - Timer? _routeArrivalHoverTimer; - - @override - void dispose() { - _closeTimer?.cancel(); - _routeArrivalHoverTimer?.cancel(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final workspace = ref.watch(libraryWorkspaceProvider); - final cloudSection = ref.watch(cloudLibrarySectionProvider); - final cloudAvailable = ref.watch(isCloudWorkspaceAvailableProvider); - final authState = ref.watch(authProvider); - - final items = [ - _LibraryRailItemData( - key: const ValueKey('library-local'), - icon: LucideIcons.monitor, - label: 'This Computer', - semanticsLabel: 'This Computer library', - description: 'Local strategies and imports', - selected: workspace == LibraryWorkspace.local, - onTap: () => _selectLocal(), - ), - _LibraryRailItemData( - key: const ValueKey('library-cloud'), - icon: LucideIcons.cloud, - label: 'Cloud', - semanticsLabel: 'Cloud library', - description: cloudAvailable - ? 'Your online strategies' - : 'Log in to sync strategies', - selected: workspace == LibraryWorkspace.cloud && - cloudSection == CloudLibrarySection.home, - onTap: cloudAvailable ? () => _selectCloudHome() : _showAuthDialog, - ), - _LibraryRailItemData( - key: const ValueKey('library-shared'), - icon: LucideIcons.users, - label: 'Shared', - semanticsLabel: 'Shared library', - description: cloudAvailable - ? 'Strategies shared with you' - : 'Log in to view shared strategies', - selected: workspace == LibraryWorkspace.cloud && - cloudSection == CloudLibrarySection.sharedWithMe, - onTap: cloudAvailable ? () => _selectShared() : _showAuthDialog, - ), - _LibraryRailItemData( - key: const ValueKey('library-community'), - icon: Icons.public, - label: 'Community', - semanticsLabel: 'Community library', - description: 'Public strategy library', - selected: workspace == LibraryWorkspace.community, - onTap: () => _selectCommunity(), - ), - ]; - - return MouseRegion( - onEnter: (_) { - if (ref.read(suppressLibraryRailHoverProvider)) { - _routeArrivalHoverTimer?.cancel(); - _routeArrivalHoverTimer = Timer(_routeArrivalHoverDelay, () { - if (!mounted) { - return; - } - ref.read(suppressLibraryRailHoverProvider.notifier).state = false; - }); - return; - } - _closeTimer?.cancel(); - setState(() => _expanded = true); - Future.delayed(_detailsDelay, () { - if (!mounted || !_expanded) { - return; - } - setState(() => _showExpandedContent = true); - }); - }, - onExit: (_) { - _routeArrivalHoverTimer?.cancel(); - if (ref.read(suppressLibraryRailHoverProvider)) { - ref.read(suppressLibraryRailHoverProvider.notifier).state = false; - } - _closeTimer?.cancel(); - _closeTimer = Timer(_closeDelay, () { - if (!mounted) { - return; - } - setState(() { - _showExpandedContent = false; - _expanded = false; - }); - }); - }, - child: AnimatedContainer( - duration: const Duration(milliseconds: 180), - curve: Curves.easeOutCubic, - width: _expanded ? 184 : 64, - margin: EdgeInsets.zero, - decoration: BoxDecoration( - color: Settings.tacticalVioletTheme.card.withValues(alpha: 0.96), - borderRadius: const BorderRadius.only( - // topRight: Radius.circular(14), - // bottomRight: Radius.circular(14), - ), - border: Border.all(color: Settings.tacticalVioletTheme.border), - boxShadow: const [Settings.cardForegroundBackdrop], - ), - child: ClipRRect( - borderRadius: const BorderRadius.only( - // topRight: Radius.circular(14), - // bottomRight: Radius.circular(14), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(8, 12, 8, 8), - child: _RailHeader( - expanded: _expanded, - showDetails: _showExpandedContent, - ), - ), - Divider(height: 1, color: Settings.tacticalVioletTheme.border), - Expanded( - child: Padding( - padding: const EdgeInsets.fromLTRB(8, 10, 8, 10), - child: Column( - children: [ - for (final item in items) ...[ - _LibraryRailItem( - key: item.key, - data: item, - expanded: _expanded, - showDetails: _showExpandedContent, - ), - const SizedBox(height: 8), - ], - const Spacer(), - _AccountRailItem( - key: const ValueKey('library-account-action'), - expanded: _expanded, - showDetails: _showExpandedContent, - isLoading: authState.isLoading, - isAuthenticated: authState.isAuthenticated, - avatarUrl: authState.avatarUrl, - label: authState.isAuthenticated - ? authState.displayName - : 'Log In', - semanticsLabel: authState.isAuthenticated - ? 'Account for ${authState.displayName}' - : 'Log in to Icarus', - onAuthAction: authState.isLoading - ? null - : () async { - if (authState.isAuthenticated) { - await ref - .read(guardedSignOutRequestProvider)( - context, - ); - } else { - showDialog( - context: context, - builder: (_) => const AuthDialog(), - ); - } - }, - ), - ], - ), - ), - ), - ], - ), - ), - ), - ); - } - - void _selectLocal() { - ref.read(libraryWorkspaceProvider.notifier).select(LibraryWorkspace.local); - ref.read(folderProvider.notifier).updateID(null); - } - - void _showAuthDialog() { - showDialog( - context: context, - builder: (_) => const AuthDialog(), - ); - } - - void _selectCloudHome() { - ref.read(libraryWorkspaceProvider.notifier).select(LibraryWorkspace.cloud); - ref - .read(cloudLibrarySectionProvider.notifier) - .select(CloudLibrarySection.home); - ref.read(folderProvider.notifier).updateID(null); - } - - void _selectShared() { - ref.read(libraryWorkspaceProvider.notifier).select(LibraryWorkspace.cloud); - ref - .read(cloudLibrarySectionProvider.notifier) - .select(CloudLibrarySection.sharedWithMe); - ref.read(folderProvider.notifier).updateID(null); - } - - void _selectCommunity() { - ref - .read(libraryWorkspaceProvider.notifier) - .select(LibraryWorkspace.community); - ref.read(folderProvider.notifier).updateID(null); - } -} - -class _RailHeader extends StatelessWidget { - const _RailHeader({ - required this.expanded, - required this.showDetails, - }); - - final bool expanded; - final bool showDetails; @override - Widget build(BuildContext context) { - return SizedBox( - height: 42, - child: LayoutBuilder( - builder: (context, constraints) { - final showLabel = showDetails && constraints.maxWidth >= 96; - return Stack( - clipBehavior: Clip.none, - children: [ - Positioned( - left: 0, - top: 0, - bottom: 0, - width: 48, - child: Center( - child: SizedBox( - width: 32, - height: 32, - child: Image.asset( - 'assets/icarus-icon.webp', - fit: BoxFit.contain, - ), - ), - ), - ), - Positioned.fill( - left: 50, - child: IgnorePointer( - ignoring: !showLabel, - child: AnimatedOpacity( - duration: const Duration(milliseconds: 120), - opacity: expanded && showLabel ? 1 : 0, - child: const Align( - alignment: Alignment.centerLeft, - child: Text( - 'Icarus', - overflow: TextOverflow.ellipsis, - style: TextStyle(fontWeight: FontWeight.w800), - ), - ), - ), - ), - ), - ], - ); - }, - ), - ); - } -} - -class _LibraryRailItemData { - const _LibraryRailItemData({ - required this.key, - required this.icon, - required this.label, - required this.semanticsLabel, - required this.description, - required this.selected, - required this.onTap, - }); - - final Key key; - final IconData icon; - final String label; - final String semanticsLabel; - final String description; - final bool selected; - final VoidCallback? onTap; -} - -class _LibraryRailItem extends StatelessWidget { - const _LibraryRailItem({ - super.key, - required this.data, - required this.expanded, - required this.showDetails, - }); - - final _LibraryRailItemData data; - final bool expanded; - final bool showDetails; - - @override - Widget build(BuildContext context) { - final selectedColor = - Settings.tacticalVioletTheme.primary.withValues(alpha: 0.18); - return Semantics( - label: data.semanticsLabel, - button: true, - enabled: data.onTap != null, - selected: data.selected, - onTap: data.onTap, - excludeSemantics: true, - child: Tooltip( - message: data.description, - waitDuration: const Duration(milliseconds: 500), - child: Material( - color: Colors.transparent, - child: InkWell( - borderRadius: BorderRadius.circular(10), - mouseCursor: data.onTap == null - ? SystemMouseCursors.basic - : SystemMouseCursors.click, - onTap: data.onTap, - child: AnimatedOpacity( - duration: const Duration(milliseconds: 140), - opacity: data.onTap == null ? 0.55 : 1, - child: AnimatedContainer( - duration: const Duration(milliseconds: 140), - height: 40, - padding: const EdgeInsets.symmetric(horizontal: 9), - decoration: BoxDecoration( - color: data.selected ? selectedColor : Colors.transparent, - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: data.selected - ? Settings.tacticalVioletTheme.primary - : Colors.transparent, - ), - ), - child: LayoutBuilder( - builder: (context, constraints) { - final showLabel = showDetails && constraints.maxWidth >= 96; - return Stack( - clipBehavior: Clip.none, - children: [ - Positioned( - left: 0, - top: 0, - bottom: 0, - width: 26, - child: Align( - alignment: Alignment.center, - child: Icon( - data.icon, - size: 18, - color: data.onTap == null - ? Settings.tacticalVioletTheme.mutedForeground - : null, - ), - ), - ), - Positioned.fill( - left: 33, - child: IgnorePointer( - ignoring: !showLabel, - child: AnimatedOpacity( - duration: const Duration(milliseconds: 120), - opacity: expanded && showLabel ? 1 : 0, - child: Align( - alignment: Alignment.centerLeft, - child: Text( - data.label, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontWeight: FontWeight.w600, - fontSize: 13, - ), - ), - ), - ), - ), - ), - ], - ); - }, - ), - ), - ), - ), - ), - ), - ); - } -} - -class _AccountRailItem extends StatelessWidget { - const _AccountRailItem({ - super.key, - required this.expanded, - required this.showDetails, - required this.isLoading, - required this.isAuthenticated, - required this.avatarUrl, - required this.label, - required this.semanticsLabel, - required this.onAuthAction, - }); - - final bool expanded; - final bool showDetails; - final bool isLoading; - final bool isAuthenticated; - final String? avatarUrl; - final String label; - final String semanticsLabel; - final VoidCallback? onAuthAction; - - @override - Widget build(BuildContext context) { - return Semantics( - label: semanticsLabel, - button: true, - enabled: onAuthAction != null, - onTap: onAuthAction, - excludeSemantics: true, - child: Material( - color: Colors.transparent, - child: InkWell( - borderRadius: BorderRadius.circular(10), - mouseCursor: onAuthAction == null - ? SystemMouseCursors.basic - : SystemMouseCursors.click, - onTap: onAuthAction, - child: AnimatedContainer( - duration: const Duration(milliseconds: 140), - curve: Curves.easeOutCubic, - height: 48, - padding: const EdgeInsets.symmetric(horizontal: 9), - decoration: BoxDecoration( - color: Settings.tacticalVioletTheme.secondary.withValues( - alpha: 0.5, - ), - borderRadius: BorderRadius.circular(10), - border: Border.all(color: Settings.tacticalVioletTheme.border), - ), - child: LayoutBuilder( - builder: (context, constraints) { - final showLabel = showDetails && constraints.maxWidth >= 96; - return Stack( - clipBehavior: Clip.none, - children: [ - Positioned( - left: 0, - top: 0, - bottom: 0, - width: 28, - child: Align( - alignment: Alignment.center, - child: _AccountAvatar( - avatarUrl: avatarUrl, - isAuthenticated: isAuthenticated, - ), - ), - ), - Positioned.fill( - left: 38, - child: IgnorePointer( - ignoring: !showLabel, - child: AnimatedOpacity( - duration: const Duration(milliseconds: 120), - curve: Curves.easeOutCubic, - opacity: expanded && showLabel ? 1 : 0, - child: showLabel - ? Row( - children: [ - Expanded( - child: Text( - isLoading ? 'Please wait...' : label, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontWeight: FontWeight.w700, - ), - ), - ), - ], - ) - : const SizedBox.shrink(), - ), - ), - ), - ], - ); - }, - ), - ), - ), - ), - ); - } -} - -class _AccountAvatar extends StatelessWidget { - const _AccountAvatar({ - required this.avatarUrl, - required this.isAuthenticated, - }); - - final String? avatarUrl; - final bool isAuthenticated; - - @override - Widget build(BuildContext context) { - return AccountAvatar( - radius: 14, - backgroundColor: Settings.tacticalVioletTheme.card, - avatarUrl: isAuthenticated ? avatarUrl : null, - fallback: Icon( - isAuthenticated ? Icons.person : LucideIcons.userRound, - size: 15, - ), - ); - } + LibraryWorkspace get store => + strategy == null ? LibraryWorkspace.cloud : LibraryWorkspace.local; } diff --git a/lib/widgets/folder_navigator_sidebar.dart b/lib/widgets/folder_navigator_sidebar.dart deleted file mode 100644 index 545e54e4..00000000 --- a/lib/widgets/folder_navigator_sidebar.dart +++ /dev/null @@ -1,963 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:hive_ce_flutter/adapters.dart'; -import 'package:icarus/const/hive_boxes.dart'; -import 'package:icarus/const/settings.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/strategy_filter_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/custom_search_field.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'; -import 'package:shadcn_ui/shadcn_ui.dart'; - -const _rowHoverDuration = Duration(milliseconds: 120); -const _treeRevealDuration = Duration(milliseconds: 180); -const _sortIconSwapDuration = Duration(milliseconds: 150); -const _rowHeight = 34.0; -const _rowIconSize = 16.0; -const _chevronSlotWidth = 18.0; -const _depthIndent = 14.0; - -class FolderNavigatorSidebar extends ConsumerWidget { - const FolderNavigatorSidebar({ - super.key, - required this.onCreateStrategy, - required this.onAddFolder, - required this.onImportIca, - required this.onImportBackup, - required this.onExportLibrary, - }); - - final VoidCallback onCreateStrategy; - final Future Function() onAddFolder; - final Future Function() onImportIca; - final Future Function() onImportBackup; - final Future Function() onExportLibrary; - - static final foldersListenable = - Provider>>((ref) { - return Hive.box(HiveBoxNames.foldersBox).listenable(); - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final workspace = ref.watch(libraryWorkspaceProvider); - final isCloud = workspace == LibraryWorkspace.cloud; - - if (isCloud) { - final cloudFolders = - (ref.watch(cloudAllFoldersProvider).valueOrNull ?? const []) - .map((entry) => entry.folder) - .toList(growable: false); - return _SidebarShell( - folders: cloudFolders, - isCloud: true, - onCreateStrategy: onCreateStrategy, - onAddFolder: onAddFolder, - onImportIca: onImportIca, - onImportBackup: onImportBackup, - onExportLibrary: onExportLibrary, - ); - } - - final localFoldersListenable = ref.watch( - FolderNavigatorSidebar.foldersListenable, - ); - return ValueListenableBuilder>( - valueListenable: localFoldersListenable, - builder: (context, folderBox, _) { - return _SidebarShell( - folders: folderBox.values.toList(growable: false), - isCloud: false, - onCreateStrategy: onCreateStrategy, - onAddFolder: onAddFolder, - onImportIca: onImportIca, - onImportBackup: onImportBackup, - onExportLibrary: onExportLibrary, - ); - }, - ); - } -} - -class _SidebarShell extends ConsumerWidget { - const _SidebarShell({ - required this.folders, - required this.isCloud, - required this.onCreateStrategy, - required this.onAddFolder, - required this.onImportIca, - required this.onImportBackup, - required this.onExportLibrary, - }); - - final List folders; - final bool isCloud; - final VoidCallback onCreateStrategy; - final Future Function() onAddFolder; - final Future Function() onImportIca; - final Future Function() onImportBackup; - final Future Function() onExportLibrary; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final currentFolderId = ref.watch(folderProvider); - final cloudSection = ref.watch(cloudLibrarySectionProvider); - final isSharedWithMe = - isCloud && cloudSection == CloudLibrarySection.sharedWithMe; - final filterState = ref.watch(strategyFilterProvider); - final searchQuery = - ref.watch(strategySearchQueryProvider).trim().toLowerCase(); - final visibleRoots = _buildVisibleTree(folders, searchQuery); - final folderLookup = {for (final folder in folders) folder.id: folder}; - - return Container( - width: 240, - margin: const EdgeInsets.fromLTRB(12, 12, 0, 12), - decoration: BoxDecoration( - color: Settings.tacticalVioletTheme.card, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: Settings.tacticalVioletTheme.border), - boxShadow: const [Settings.cardForegroundBackdrop], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(12, 12, 12, 10), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (isSharedWithMe) - ShadButton( - onPressed: () => showAddSharedItemDialog(context), - leading: const Icon(LucideIcons.link, size: 16), - child: const Text('Add by Link or Code'), - ) - else ...[ - ShadButton( - onPressed: onCreateStrategy, - leading: const Icon(Icons.add, size: 16), - child: Text( - isCloud ? 'Create Cloud Strategy' : 'Create Strategy', - ), - ), - const SizedBox(height: 6), - ShadButton.secondary( - onPressed: onAddFolder, - leading: const Icon(LucideIcons.folderPlus, size: 16), - child: const Text('Add Folder'), - ), - ], - const SizedBox(height: 10), - const SizedBox( - height: 36, - child: SearchTextField( - collapsedWidth: double.infinity, - expandedWidth: double.infinity, - compact: true, - hintText: 'Search strategies and folders', - ), - ), - const SizedBox(height: 8), - Row( - children: [ - Expanded( - // Keyed so external sort-by changes (e.g. a future - // "reset filters") re-seed the select's internal state. - child: ShadSelect( - key: ValueKey(filterState.sortBy), - initialValue: filterState.sortBy, - selectedOptionBuilder: (context, value) => Text( - StrategyFilterProvider.sortByLabels[value]!, - ), - options: [ - for (final value in SortBy.values) - ShadOption( - value: value, - child: Text( - StrategyFilterProvider.sortByLabels[value]!, - ), - ), - ], - onChanged: (value) { - if (value != null) { - ref - .read(strategyFilterProvider.notifier) - .setSortBy(value); - } - }, - ), - ), - const SizedBox(width: 6), - _SortOrderToggle(sortOrder: filterState.sortOrder), - ], - ), - if (!isCloud) ...[ - const SizedBox(height: 12), - const _SidebarSectionLabel(label: 'Library Tools'), - const SizedBox(height: 6), - _SidebarActionButton( - icon: Icons.file_download_outlined, - label: 'Import .ica', - onPressed: onImportIca, - ), - const SizedBox(height: 4), - _SidebarActionButton( - icon: Icons.archive_outlined, - label: 'Import Backup', - onPressed: onImportBackup, - ), - const SizedBox(height: 4), - _SidebarActionButton( - icon: Icons.backup_outlined, - label: 'Export Library', - onPressed: onExportLibrary, - ), - ], - ], - ), - ), - Divider( - height: 1, - color: Settings.tacticalVioletTheme.border, - ), - Expanded( - child: Padding( - padding: const EdgeInsets.fromLTRB(8, 10, 8, 10), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (isCloud) ...[ - const Padding( - padding: EdgeInsets.symmetric(horizontal: 6), - child: _SidebarSectionLabel(label: 'Views'), - ), - const SizedBox(height: 6), - _SidebarRootItem( - isSelected: cloudSection == CloudLibrarySection.home && - currentFolderId == null, - ), - const SizedBox(height: 2), - _SidebarSpecialItem( - icon: Icons.people_outline, - label: 'Shared with Me', - isSelected: - cloudSection == CloudLibrarySection.sharedWithMe, - onTap: () { - ref - .read(cloudLibrarySectionProvider.notifier) - .select(CloudLibrarySection.sharedWithMe); - ref.read(folderProvider.notifier).updateID(null); - }, - ), - const SizedBox(height: 10), - ], - const Padding( - padding: EdgeInsets.symmetric(horizontal: 6), - child: _SidebarSectionLabel(label: 'Folders'), - ), - const SizedBox(height: 6), - Expanded( - child: ListView( - children: [ - if (!isCloud) ...[ - _SidebarRootItem(isSelected: currentFolderId == null), - const SizedBox(height: 2), - ], - if (visibleRoots.isEmpty) - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 12, - ), - child: Text( - searchQuery.isEmpty - ? 'No folders yet' - : 'No folders match your search', - style: TextStyle( - color: Settings - .tacticalVioletTheme.mutedForeground, - fontSize: 13, - ), - ), - ) - else - ...visibleRoots.map( - (node) => _FolderSidebarItem( - key: ValueKey(node.folder.id), - node: node, - depth: 0, - selectedFolderId: currentFolderId, - folderLookup: folderLookup, - forceExpanded: searchQuery.isNotEmpty, - ), - ), - ], - ), - ), - ], - ), - ), - ), - ], - ), - ); - } -} - -class _SortOrderToggle extends ConsumerWidget { - const _SortOrderToggle({required this.sortOrder}); - - final SortOrder sortOrder; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final isAscending = sortOrder == SortOrder.ascending; - return Tooltip( - message: isAscending ? 'Sort descending' : 'Sort ascending', - child: ShadButton.outline( - width: 36, - height: 36, - padding: EdgeInsets.zero, - onPressed: () { - ref.read(strategyFilterProvider.notifier).setSortOrder( - isAscending ? SortOrder.descending : SortOrder.ascending, - ); - }, - child: AnimatedSwitcher( - duration: _sortIconSwapDuration, - switchInCurve: Curves.easeOutCubic, - switchOutCurve: Curves.easeOutCubic, - transitionBuilder: (child, animation) => FadeTransition( - opacity: animation, - child: ScaleTransition(scale: animation, child: child), - ), - child: Icon( - isAscending - ? LucideIcons.arrowUpNarrowWide - : LucideIcons.arrowDownWideNarrow, - key: ValueKey(isAscending), - size: 16, - ), - ), - ), - ); - } -} - -class _SidebarRootItem extends ConsumerWidget { - const _SidebarRootItem({required this.isSelected}); - - final bool isSelected; - - @override - Widget build(BuildContext context, WidgetRef ref) { - return DragTarget( - onAcceptWithDetails: (details) async { - final item = details.data; - if (item is StrategyItem) { - await ref.read(strategyProvider.notifier).moveToFolder( - strategyID: item.strategyId, - parentID: null, - source: item.strategy == null - ? StrategySource.cloud - : StrategySource.local, - ); - } else if (item is FolderItem) { - await ref.read(folderProvider.notifier).moveToFolder( - folderID: item.folder.id, - parentID: null, - workspace: ref.read(libraryWorkspaceProvider), - ); - } - }, - builder: (context, candidateData, rejectedData) { - final isDropTarget = candidateData.isNotEmpty; - return _SidebarRowShell( - selected: isSelected, - isDropTarget: isDropTarget, - onTap: () { - if (ref.read(libraryWorkspaceProvider) == LibraryWorkspace.cloud) { - ref - .read(cloudLibrarySectionProvider.notifier) - .select(CloudLibrarySection.home); - } - ref.read(folderProvider.notifier).updateID(null); - }, - child: const Row( - children: [ - SizedBox(width: _chevronSlotWidth), - Icon(Icons.home_outlined, size: _rowIconSize), - SizedBox(width: 10), - Expanded( - child: Text( - 'Home', - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ); - }, - ); - } -} - -class _SidebarSpecialItem extends StatelessWidget { - const _SidebarSpecialItem({ - required this.icon, - required this.label, - required this.isSelected, - required this.onTap, - }); - - final IconData icon; - final String label; - final bool isSelected; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - return _SidebarRowShell( - selected: isSelected, - isDropTarget: false, - onTap: onTap, - child: Row( - children: [ - const SizedBox(width: _chevronSlotWidth), - Icon(icon, size: _rowIconSize), - const SizedBox(width: 10), - Expanded( - child: Text( - label, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ); - } -} - -class _FolderSidebarItem extends ConsumerStatefulWidget { - const _FolderSidebarItem({ - super.key, - required this.node, - required this.depth, - required this.selectedFolderId, - required this.folderLookup, - required this.forceExpanded, - }); - - final _FolderTreeNode node; - final int depth; - final String? selectedFolderId; - final Map folderLookup; - final bool forceExpanded; - - @override - ConsumerState<_FolderSidebarItem> createState() => _FolderSidebarItemState(); -} - -class _FolderSidebarItemState extends ConsumerState<_FolderSidebarItem> { - final ShadContextMenuController _menuButtonController = - ShadContextMenuController(); - final ShadContextMenuController _rightClickMenuController = - ShadContextMenuController(); - bool _hovered = false; - bool _expanded = false; - - Folder get folder => widget.node.folder; - - @override - void initState() { - super.initState(); - _expanded = _containsSelected(widget.node); - _menuButtonController.addListener(_onMenuStateChanged); - } - - @override - void didUpdateWidget(covariant _FolderSidebarItem oldWidget) { - super.didUpdateWidget(oldWidget); - if (widget.selectedFolderId != oldWidget.selectedFolderId && - !_expanded && - _containsSelected(widget.node)) { - _expanded = true; - } - } - - bool _containsSelected(_FolderTreeNode node) { - final selectedId = widget.selectedFolderId; - if (selectedId == null) { - return false; - } - for (final child in node.children) { - if (child.folder.id == selectedId || _containsSelected(child)) { - return true; - } - } - return false; - } - - void _onMenuStateChanged() { - if (mounted) { - setState(() {}); - } - } - - @override - void dispose() { - _menuButtonController.removeListener(_onMenuStateChanged); - _menuButtonController.dispose(); - _rightClickMenuController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final color = folder.customColor ?? - Folder.folderColorMap[folder.color] ?? - Colors.white; - final selected = widget.selectedFolderId == folder.id; - final hasChildren = widget.node.children.isNotEmpty; - final showChildren = hasChildren && (_expanded || widget.forceExpanded); - final showMenuButton = _hovered || selected || _menuButtonController.isOpen; - - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - DragTarget( - onWillAcceptWithDetails: (details) { - final item = details.data; - if (item is FolderItem) { - return item.folder.id != folder.id && - !_isAncestor( - targetFolder: folder, draggedFolderId: item.folder.id); - } - return true; - }, - onAcceptWithDetails: (details) async { - final item = details.data; - if (item is StrategyItem) { - await ref.read(strategyProvider.notifier).moveToFolder( - strategyID: item.strategyId, - parentID: folder.id, - source: item.strategy == null - ? StrategySource.cloud - : StrategySource.local, - ); - } else if (item is FolderItem) { - await ref.read(folderProvider.notifier).moveToFolder( - folderID: item.folder.id, - parentID: folder.id, - workspace: ref.read(libraryWorkspaceProvider), - ); - } - }, - builder: (context, candidateData, rejectedData) { - return Padding( - padding: EdgeInsets.only(left: widget.depth * _depthIndent), - child: MouseRegion( - onEnter: (_) => setState(() => _hovered = true), - onExit: (_) => setState(() => _hovered = false), - child: ShadContextMenuRegion( - controller: _rightClickMenuController, - items: _buildMenuItems(), - child: Draggable( - data: FolderItem(folder), - feedback: _FolderDragPreview(folder: folder), - dragAnchorStrategy: pointerDragAnchorStrategy, - child: _SidebarRowShell( - selected: selected, - isDropTarget: candidateData.isNotEmpty, - onTap: () { - if (ref.read(libraryWorkspaceProvider) == - LibraryWorkspace.cloud) { - ref - .read(cloudLibrarySectionProvider.notifier) - .select(CloudLibrarySection.home); - } - ref.read(folderProvider.notifier).updateID(folder.id); - }, - child: Row( - children: [ - _ChevronSlot( - hasChildren: hasChildren, - expanded: showChildren, - onTap: hasChildren - ? () => setState(() => _expanded = !_expanded) - : null, - ), - Icon(folder.icon, size: _rowIconSize, color: color), - const SizedBox(width: 10), - Expanded( - child: Text( - folder.name, - overflow: TextOverflow.ellipsis, - style: - const TextStyle(fontWeight: FontWeight.w500), - ), - ), - if (showMenuButton) - ShadContextMenuRegion( - controller: _menuButtonController, - items: _buildMenuItems(), - child: ShadIconButton.ghost( - width: 24, - height: 24, - onPressed: _menuButtonController.toggle, - icon: const Icon(Icons.more_horiz, size: 14), - ), - ), - ], - ), - ), - ), - ), - ), - ); - }, - ), - ClipRect( - child: AnimatedSize( - duration: _treeRevealDuration, - curve: Curves.easeOutCubic, - alignment: Alignment.topCenter, - child: showChildren - ? Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - for (final child in widget.node.children) - _FolderSidebarItem( - key: ValueKey(child.folder.id), - node: child, - depth: widget.depth + 1, - selectedFolderId: widget.selectedFolderId, - folderLookup: widget.folderLookup, - forceExpanded: widget.forceExpanded, - ), - ], - ) - : const SizedBox(width: double.infinity), - ), - ), - ], - ); - } - - List _buildMenuItems() { - final isCloud = - ref.read(libraryWorkspaceProvider) == LibraryWorkspace.cloud; - final allFolders = - ref.read(cloudAllFoldersProvider).valueOrNull ?? const []; - final cloudRole = allFolders - .where((entry) => entry.folder.id == folder.id) - .map((entry) => entry.role) - .firstOrNull; - final canManage = !isCloud || cloudRole == 'owner'; - - return [ - ShadContextMenuItem( - leading: const Icon(Icons.text_fields), - onPressed: !canManage - ? null - : () async { - await showDialog( - context: context, - builder: (context) => FolderEditDialog(folder: folder), - ); - }, - child: const Text('Edit'), - ), - if (isCloud && cloudRole == 'owner') - ShadContextMenuItem( - leading: const Icon(LucideIcons.link2), - child: const Text('Share'), - onPressed: () async { - await showShadDialog( - context: context, - builder: (_) => ShareLinksDialog( - targetType: 'folder', - targetPublicId: folder.id, - title: folder.name, - ), - ); - }, - ), - ShadContextMenuItem( - leading: const Icon(Icons.file_upload_outlined), - onPressed: () async { - await StrategyImportExportService(ref).exportFolder(folder.id); - }, - child: const Text('Export'), - ), - ShadContextMenuItem( - leading: Icon( - Icons.delete_outline, - color: Settings.tacticalVioletTheme.destructive, - ), - onPressed: !canManage - ? null - : () async { - await showShadDialog( - context: context, - builder: (_) => DeleteFolderAlertDialog( - folder: folder, - workspace: ref.read(libraryWorkspaceProvider), - ), - ); - }, - child: Text( - 'Delete', - style: TextStyle(color: Settings.tacticalVioletTheme.destructive), - ), - ), - ]; - } - - bool _isAncestor({ - required Folder targetFolder, - required String draggedFolderId, - }) { - String? currentParentId = targetFolder.parentID; - while (currentParentId != null) { - if (currentParentId == draggedFolderId) { - return true; - } - currentParentId = widget.folderLookup[currentParentId]?.parentID; - } - return false; - } -} - -class _ChevronSlot extends StatelessWidget { - const _ChevronSlot({ - required this.hasChildren, - required this.expanded, - required this.onTap, - }); - - final bool hasChildren; - final bool expanded; - final VoidCallback? onTap; - - @override - Widget build(BuildContext context) { - if (!hasChildren) { - return const SizedBox(width: _chevronSlotWidth); - } - return SizedBox( - width: _chevronSlotWidth, - height: _rowHeight, - child: InkWell( - borderRadius: BorderRadius.circular(4), - onTap: onTap, - child: AnimatedRotation( - duration: _treeRevealDuration, - curve: Curves.easeOutCubic, - turns: expanded ? 0.25 : 0, - child: Icon( - Icons.chevron_right, - size: 15, - color: Settings.tacticalVioletTheme.mutedForeground, - ), - ), - ), - ); - } -} - -class _SidebarRowShell extends StatefulWidget { - const _SidebarRowShell({ - required this.child, - required this.onTap, - required this.selected, - required this.isDropTarget, - }); - - final Widget child; - final VoidCallback onTap; - final bool selected; - final bool isDropTarget; - - @override - State<_SidebarRowShell> createState() => _SidebarRowShellState(); -} - -class _SidebarRowShellState extends State<_SidebarRowShell> { - bool _hovered = false; - - @override - Widget build(BuildContext context) { - final borderColor = widget.isDropTarget - ? Settings.tacticalVioletTheme.ring - : (widget.selected - ? Settings.tacticalVioletTheme.primary - : Colors.transparent); - final backgroundColor = widget.selected - ? Settings.tacticalVioletTheme.primary.withValues(alpha: 0.18) - : (widget.isDropTarget - ? Settings.tacticalVioletTheme.primary.withValues(alpha: 0.10) - : (_hovered - ? Settings.tacticalVioletTheme.muted.withValues(alpha: 0.5) - : Colors.transparent)); - - return Padding( - padding: const EdgeInsets.symmetric(vertical: 1), - child: MouseRegion( - onEnter: (_) => setState(() => _hovered = true), - onExit: (_) => setState(() => _hovered = false), - child: Material( - color: Colors.transparent, - child: InkWell( - borderRadius: BorderRadius.circular(8), - onTap: widget.onTap, - child: AnimatedContainer( - duration: _rowHoverDuration, - height: _rowHeight, - padding: const EdgeInsets.only(left: 4, right: 6), - decoration: BoxDecoration( - color: backgroundColor, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: borderColor), - ), - child: widget.child, - ), - ), - ), - ), - ); - } -} - -extension on Iterable { - String? get firstOrNull => isEmpty ? null : first; -} - -class _SidebarSectionLabel extends StatelessWidget { - const _SidebarSectionLabel({required this.label}); - - final String label; - - @override - Widget build(BuildContext context) { - return Text( - label, - style: TextStyle( - color: Settings.tacticalVioletTheme.mutedForeground, - fontSize: 11, - fontWeight: FontWeight.w700, - letterSpacing: 0.4, - ), - ); - } -} - -class _SidebarActionButton extends StatelessWidget { - const _SidebarActionButton({ - required this.icon, - required this.label, - required this.onPressed, - }); - - final IconData icon; - final String label; - final Future Function()? onPressed; - - @override - Widget build(BuildContext context) { - return ShadButton.ghost( - size: ShadButtonSize.sm, - onPressed: onPressed, - mainAxisAlignment: MainAxisAlignment.start, - leading: Icon(icon, size: 16), - child: Text(label), - ); - } -} - -class _FolderDragPreview extends StatelessWidget { - const _FolderDragPreview({required this.folder}); - - final Folder folder; - - @override - Widget build(BuildContext context) { - final color = folder.customColor ?? - Folder.folderColorMap[folder.color] ?? - Colors.white; - return Material( - color: Colors.transparent, - child: Container( - height: 40, - padding: const EdgeInsets.symmetric(horizontal: 12), - decoration: BoxDecoration( - color: Settings.tacticalVioletTheme.card, - borderRadius: BorderRadius.circular(10), - border: Border.all(color: Settings.tacticalVioletTheme.ring), - boxShadow: const [Settings.cardForegroundBackdrop], - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(folder.icon, size: 18, color: color), - const SizedBox(width: 10), - Text( - folder.name, - style: const TextStyle(fontWeight: FontWeight.w600), - ), - ], - ), - ), - ); - } -} - -class _FolderTreeNode { - const _FolderTreeNode({ - required this.folder, - required this.children, - }); - - final Folder folder; - final List<_FolderTreeNode> children; -} - -List<_FolderTreeNode> _buildVisibleTree( - List folders, - String searchQuery, -) { - final byParent = >{}; - for (final folder in folders) { - byParent.putIfAbsent(folder.parentID, () => []).add(folder); - } - - for (final entry in byParent.entries) { - entry.value.sort((a, b) => a.dateCreated.compareTo(b.dateCreated)); - } - - List<_FolderTreeNode> walk(String? parentId) { - final children = byParent[parentId] ?? const []; - final nodes = <_FolderTreeNode>[]; - for (final folder in children) { - final nested = walk(folder.id); - final matchesSearch = searchQuery.isEmpty || - folder.name.toLowerCase().contains(searchQuery); - if (matchesSearch || nested.isNotEmpty) { - nodes.add(_FolderTreeNode(folder: folder, children: nested)); - } - } - return nodes; - } - - return walk(null); -} diff --git a/lib/widgets/folder_pill.dart b/lib/widgets/folder_pill.dart index 4c883731..c028dc41 100644 --- a/lib/widgets/folder_pill.dart +++ b/lib/widgets/folder_pill.dart @@ -25,12 +25,17 @@ class FolderPill extends ConsumerStatefulWidget { const FolderPill({ super.key, required this.folder, + required this.store, this.isDemo = false, this.strategyCount, this.folderCount, }); final Folder folder; + + /// Which library the folder belongs to. My Library lists both stores, so + /// the pill cannot infer this from the active workspace. + final LibraryWorkspace store; final bool isDemo; final int? strategyCount; final int? folderCount; @@ -98,8 +103,7 @@ class _FolderPillState extends ConsumerState Folder.folderColorMap[widget.folder.color] ?? Colors.grey; - bool get _isCloudWorkspace => - ref.read(libraryWorkspaceProvider) == LibraryWorkspace.cloud; + bool get _isCloudWorkspace => widget.store == LibraryWorkspace.cloud; String? get _cloudRole { if (!_isCloudWorkspace) { @@ -146,12 +150,13 @@ class _FolderPillState extends ConsumerState ), dragAnchorStrategy: pointerDragAnchorStrategy, onDragUpdate: (details) => _dragTiltController.addDelta(details.delta.dx), - data: FolderItem(widget.folder), + data: FolderItem(widget.folder, store: widget.store), child: DragTarget( onWillAcceptWithDetails: (details) { final item = details.data; if (widget.isDemo) return false; if (!_canManageCloudFolder) return false; + if (item.store != widget.store) return false; if (item is FolderItem) { return item.folder.id != id && !_isParentFolder(item.folder.id); } @@ -213,7 +218,7 @@ class _FolderPillState extends ConsumerState await ref.read(folderProvider.notifier).moveToFolder( folderID: item.folder.id, parentID: widget.folder.id, - workspace: ref.read(libraryWorkspaceProvider), + workspace: widget.store, ); } }, @@ -244,7 +249,10 @@ class _FolderPillState extends ConsumerState child: GestureDetector( onTap: () { if (widget.isDemo) return; - ref.read(folderProvider.notifier).updateID(widget.folder.id); + ref.read(folderProvider.notifier).openFolder( + folderId: widget.folder.id, + store: widget.store, + ); }, child: AnimatedBuilder( animation: _scaleAnimation, @@ -407,7 +415,10 @@ class _FolderPillState extends ConsumerState await showDialog( context: context, builder: (context) { - return FolderEditDialog(folder: widget.folder); + return FolderEditDialog( + folder: widget.folder, + store: widget.store, + ); }, ); }, @@ -447,7 +458,7 @@ class _FolderPillState extends ConsumerState context: context, builder: (_) => DeleteFolderAlertDialog( folder: widget.folder, - workspace: ref.read(libraryWorkspaceProvider), + workspace: widget.store, ), ); }, @@ -487,11 +498,10 @@ class _FolderPillState extends ConsumerState } bool _isParentFolder(String folderId) { - final workspace = ref.read(libraryWorkspaceProvider); String? currentParentId = widget.folder.parentID; while (currentParentId != null) { if (currentParentId == folderId) return true; - final parentFolder = workspace == LibraryWorkspace.local + final parentFolder = widget.store == LibraryWorkspace.local ? ref .read(folderProvider.notifier) .findLocalFolderByID(currentParentId) diff --git a/lib/widgets/library_breadcrumb.dart b/lib/widgets/library_breadcrumb.dart new file mode 100644 index 00000000..1f263295 --- /dev/null +++ b/lib/widgets/library_breadcrumb.dart @@ -0,0 +1,161 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:icarus/providers/collab/remote_library_provider.dart'; +import 'package:icarus/providers/folder_provider.dart'; +import 'package:icarus/providers/library_navigation_provider.dart'; +import 'package:icarus/providers/library_workspace_provider.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; +import 'package:icarus/widgets/folder_navigator.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +/// Where the user is inside a folder tree. Shown only inside a folder; at a +/// tab's root the tab itself says where you are. +class LibraryBreadcrumb extends ConsumerWidget { + const LibraryBreadcrumb({super.key, required this.folder}); + + final Folder folder; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final tab = ref.watch(libraryTabProvider); + final store = ref.watch(libraryWorkspaceProvider); + final pathFolders = _pathFolders(ref, store); + final parent = + pathFolders.length >= 2 ? pathFolders[pathFolders.length - 2] : null; + + void goToRoot() { + final navigation = ref.read(libraryNavigationProvider); + if (tab == LibraryTab.shared) { + navigation.showShared(); + } else { + navigation.showLibrary(); + } + } + + return Row( + children: [ + ShadIconButton.ghost( + width: 30, + height: 30, + foregroundColor: Settings.tacticalVioletTheme.mutedForeground, + onPressed: () { + if (parent == null) { + goToRoot(); + } else { + ref.read(folderProvider.notifier).updateID(parent.id); + } + }, + icon: const Icon(Icons.chevron_left, size: 20), + ), + const SizedBox(width: 4), + Expanded( + child: ShadBreadcrumb( + lastItemTextColor: Settings.tacticalVioletTheme.foreground, + textStyle: ShadTheme.of(context).textTheme.lead, + children: [ + FolderTab( + folder: null, + label: + tab == LibraryTab.shared ? 'Shared with Me' : 'My Library', + store: store, + onOpen: goToRoot, + ), + for (int i = 0; i < pathFolders.length; i++) + FolderTab( + folder: pathFolders[i], + store: store, + isActive: i == pathFolders.length - 1, + onOpen: () => ref + .read(folderProvider.notifier) + .updateID(pathFolders[i].id), + ), + ], + ), + ), + ], + ); + } + + List _pathFolders(WidgetRef ref, LibraryWorkspace store) { + if (store == LibraryWorkspace.cloud) { + final cloudFolders = + (ref.watch(cloudAllFoldersProvider).valueOrNull ?? const []) + .map((entry) => entry.folder) + .toList(growable: false); + final path = []; + Folder? current = folder; + while (current != null) { + path.insert(0, current); + final parentId = current.parentID; + current = parentId == null + ? null + : cloudFolders.where((item) => item.id == parentId).firstOrNull; + } + return path; + } + final folders = ref.read(folderProvider.notifier); + return folders + .getFullPathIDs(folder) + .map(folders.findLocalFolderByID) + .whereType() + .toList(growable: false); + } +} + +/// One crumb. Also a drop target: dragging a strategy or folder onto it moves +/// the item there, within the same store. +class FolderTab extends ConsumerWidget { + const FolderTab({ + super.key, + required this.folder, + required this.store, + required this.onOpen, + this.label, + this.isActive = false, + }); + + /// Null for the root crumb. + final Folder? folder; + final LibraryWorkspace store; + final VoidCallback onOpen; + final String? label; + final bool isActive; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return ShadBreadcrumbLink( + textStyle: ShadTheme.of(context).textTheme.lead, + normalColor: isActive ? Settings.tacticalVioletTheme.foreground : null, + onPressed: onOpen, + child: DragTarget( + onWillAcceptWithDetails: (details) => details.data.store == store, + onAcceptWithDetails: (details) async { + final item = details.data; + if (item is StrategyItem) { + await ref.read(strategyProvider.notifier).moveToFolder( + strategyID: item.strategyId, + parentID: folder?.id, + source: item.strategy == null + ? StrategySource.cloud + : StrategySource.local, + ); + } else if (item is FolderItem) { + await ref.read(folderProvider.notifier).moveToFolder( + folderID: item.folder.id, + parentID: folder?.id, + workspace: store, + ); + } + }, + builder: (context, candidateData, rejectedData) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Text(label ?? folder?.name ?? 'My Library'), + ); + }, + ), + ); + } +} diff --git a/lib/widgets/library_entries.dart b/lib/widgets/library_entries.dart new file mode 100644 index 00000000..58374da8 --- /dev/null +++ b/lib/widgets/library_entries.dart @@ -0,0 +1,112 @@ +import 'package:icarus/collab/cloud_library_models.dart'; +import 'package:icarus/domain/folder.dart'; +import 'package:icarus/providers/library_workspace_provider.dart'; +import 'package:icarus/providers/strategy_filter_provider.dart'; +import 'package:icarus/strategy/strategy_models.dart'; + +/// One folder in the library grid, tagged with the store it lives in so the +/// pill knows which provider path to use for moves, edits, and opening. +class LibraryFolderRow { + const LibraryFolderRow({ + required this.folder, + required this.store, + required this.lastUpdated, + }); + + final Folder folder; + final LibraryWorkspace store; + + /// Newest edit inside the folder tree, used for the "Date updated" sort. + /// Cloud folders do not carry this yet and fall back to their creation date. + final DateTime lastUpdated; + + String get id => folder.id; +} + +/// One strategy in the library grid, from either store. +class LibraryStrategyRow { + LibraryStrategyRow.local(StrategyData strategy, {required this.showDeviceBadge}) + : local = strategy, + cloud = null; + + LibraryStrategyRow.cloud(CloudStrategyEntry entry) + : cloud = entry, + local = null, + showDeviceBadge = false; + + final StrategyData? local; + final CloudStrategyEntry? cloud; + + /// True for local strategies while the cloud is reachable, so the tile can + /// say it is not synced. + final bool showDeviceBadge; + + String get id => local?.id ?? cloud!.strategy.id; + String get name => local?.name ?? cloud!.strategy.name; + DateTime get createdAt => local?.createdAt ?? cloud!.strategy.createdAt; + DateTime get lastEdited => local?.lastEdited ?? cloud!.strategy.lastEdited; +} + +/// Combines both stores for the My Library root. A folder present in both +/// (a migrated one keeps its id) shows once, as its cloud copy. +List mergeLibraryFolders({ + required List local, + required List cloud, +}) { + final cloudIds = {for (final row in cloud) row.id}; + return [ + ...cloud, + for (final row in local) + if (!cloudIds.contains(row.id)) row, + ]; +} + +/// Same rule as [mergeLibraryFolders], for strategies. +List mergeLibraryStrategies({ + required List local, + required List cloud, +}) { + final cloudIds = {for (final row in cloud) row.id}; + return [ + ...cloud, + for (final row in local) + if (!cloudIds.contains(row.id)) row, + ]; +} + +List sortLibraryFolders( + List rows, + StrategyFilterState filter, +) { + final direction = filter.sortOrder == SortOrder.ascending ? 1 : -1; + final sorted = [...rows]; + sorted.sort((a, b) { + final result = switch (filter.sortBy) { + SortBy.alphabetical => + a.folder.name.toLowerCase().compareTo(b.folder.name.toLowerCase()), + SortBy.dateCreated => a.folder.dateCreated.compareTo(b.folder.dateCreated), + SortBy.dateUpdated => a.lastUpdated.compareTo(b.lastUpdated), + }; + if (result != 0) return direction * result; + return a.id.compareTo(b.id); + }); + return sorted; +} + +List sortLibraryStrategies( + List rows, + StrategyFilterState filter, +) { + final direction = filter.sortOrder == SortOrder.ascending ? 1 : -1; + final sorted = [...rows]; + sorted.sort((a, b) { + final result = switch (filter.sortBy) { + SortBy.alphabetical => + a.name.toLowerCase().compareTo(b.name.toLowerCase()), + SortBy.dateCreated => a.createdAt.compareTo(b.createdAt), + SortBy.dateUpdated => a.lastEdited.compareTo(b.lastEdited), + }; + return direction * result; + }); + return sorted; +} diff --git a/lib/widgets/library_title_strip.dart b/lib/widgets/library_title_strip.dart new file mode 100644 index 00000000..a135128b --- /dev/null +++ b/lib/widgets/library_title_strip.dart @@ -0,0 +1,510 @@ +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/providers/library_navigation_provider.dart'; +import 'package:icarus/providers/library_workspace_provider.dart'; +import 'package:icarus/providers/strategy_filter_provider.dart'; +import 'package:icarus/widgets/account_avatar.dart'; +import 'package:icarus/widgets/custom_search_field.dart'; +import 'package:icarus/widgets/demo_tag.dart'; +import 'package:icarus/widgets/dialogs/auth/auth_dialog.dart'; +import 'package:icarus/services/guarded_sign_out.dart'; +import 'package:icarus/widgets/dialogs/share_links_dialog.dart'; +import 'package:icarus/widgets/window_chrome.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +const double _controlHeight = 28; +// Action menus hug their labels; the account menu keeps room for email text. +const double _sortMenuWidth = 132; +const double _newMenuWidth = 140; +const double _accountMenuWidth = 200; +const double _menuItemHorizontalPadding = 8; +const double _menuIconWidth = 18; +const double _menuItemGap = 8; +const double _menuLabelLeftInset = + _menuItemHorizontalPadding + _menuIconWidth + _menuItemGap; + +/// The library's only chrome: tabs on the left, search / sort / New / account +/// on the right, all inside the window's title strip. +class LibraryTitleStrip extends ConsumerStatefulWidget { + const LibraryTitleStrip({ + super.key, + required this.onCreateStrategy, + required this.onCreateFolder, + required this.onImportIca, + required this.onImportBackup, + required this.onExportLibrary, + }); + + final VoidCallback onCreateStrategy; + final VoidCallback onCreateFolder; + final VoidCallback onImportIca; + final VoidCallback onImportBackup; + final VoidCallback onExportLibrary; + + @override + ConsumerState createState() => _LibraryTitleStripState(); +} + +class _LibraryTitleStripState extends ConsumerState { + final ShadPopoverController _sortController = ShadPopoverController(); + final ShadPopoverController _newController = ShadPopoverController(); + final ShadPopoverController _accountController = ShadPopoverController(); + + @override + void dispose() { + _sortController.dispose(); + _newController.dispose(); + _accountController.dispose(); + super.dispose(); + } + + void _showAuthDialog() { + showDialog( + context: context, + builder: (_) => const AuthDialog(), + ); + } + + @override + Widget build(BuildContext context) { + final tab = ref.watch(libraryTabProvider); + final cloudAvailable = ref.watch(isCloudWorkspaceAvailableProvider); + final navigation = ref.read(libraryNavigationProvider); + + return AppWindowStrip( + child: Row( + children: [ + const WindowsIcarusWordmark(), + const SizedBox(width: 6), + _TabButton( + key: const ValueKey('library-tab-library'), + icon: LucideIcons.folder, + label: 'My Library', + semanticsLabel: 'My Library', + selected: tab == LibraryTab.library, + onTap: navigation.showLibrary, + ), + _TabButton( + key: const ValueKey('library-tab-shared'), + icon: LucideIcons.users, + label: 'Shared', + semanticsLabel: 'Shared library', + selected: tab == LibraryTab.shared, + dimmed: !cloudAvailable, + onTap: () { + if (!navigation.showShared()) { + _showAuthDialog(); + } + }, + ), + _TabButton( + key: const ValueKey('library-tab-community'), + icon: LucideIcons.globe, + label: 'Community', + semanticsLabel: 'Community library', + selected: tab == LibraryTab.community, + onTap: navigation.showCommunity, + ), + if (kIsWeb) ...[ + const SizedBox(width: 8), + const DemoTag(), + ], + const Expanded( + child: WindowDragArea( + key: ValueKey('library-window-drag-area'), + child: SizedBox.expand(), + ), + ), + if (tab != LibraryTab.community) ...[ + const SizedBox( + height: _controlHeight, + child: SearchTextField( + key: ValueKey('library-search'), + collapsedWidth: 34, + expandedWidth: 220, + compact: true, + hintText: 'Search', + ), + ), + const SizedBox(width: 4), + _buildSortMenu(), + const SizedBox(width: 8), + if (tab == LibraryTab.shared) + ShadButton.secondary( + key: const ValueKey('cloud-add-shared-item'), + height: _controlHeight, + padding: const EdgeInsets.symmetric(horizontal: 10), + onPressed: () => showAddSharedItemDialog(context), + leading: const Icon(LucideIcons.link, size: 14), + child: const Text('Add by Link or Code'), + ) + else + _buildNewMenu(), + const SizedBox(width: 8), + ], + _buildAccount(), + const SizedBox(width: 10), + ], + ), + ); + } + + Widget _buildSortMenu() { + final filter = ref.watch(strategyFilterProvider); + final isAscending = filter.sortOrder == SortOrder.ascending; + return ShadPopover( + controller: _sortController, + padding: const EdgeInsets.all(6), + anchor: const ShadAnchor( + offset: Offset(0, 6), + childAlignment: Alignment.topRight, + overlayAlignment: Alignment.bottomRight, + ), + popover: (context) => SizedBox( + width: _sortMenuWidth, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const _MenuLabel('Sort by'), + for (final value in SortBy.values) + _MenuItem( + menu: _sortController, + icon: value == filter.sortBy ? Icons.check : null, + label: StrategyFilterProvider.sortByLabels[value]!, + onPressed: () { + ref.read(strategyFilterProvider.notifier).setSortBy(value); + }, + ), + const _MenuDivider(), + _MenuItem( + menu: _sortController, + icon: isAscending + ? LucideIcons.arrowUpNarrowWide + : LucideIcons.arrowDownWideNarrow, + label: StrategyFilterProvider.sortOrderLabels[filter.sortOrder]!, + onPressed: () { + ref.read(strategyFilterProvider.notifier).setSortOrder( + isAscending ? SortOrder.descending : SortOrder.ascending, + ); + }, + ), + ], + ), + ), + child: Tooltip( + message: 'Sort', + child: ShadIconButton.ghost( + key: const ValueKey('library-sort-menu'), + width: _controlHeight, + height: _controlHeight, + foregroundColor: Settings.tacticalVioletTheme.mutedForeground, + onPressed: _sortController.toggle, + icon: Icon( + isAscending + ? LucideIcons.arrowUpNarrowWide + : LucideIcons.arrowDownWideNarrow, + size: 16, + ), + ), + ), + ); + } + + Widget _buildNewMenu() { + const showLibraryTools = !kIsWeb; + return ShadPopover( + controller: _newController, + padding: const EdgeInsets.all(6), + anchor: const ShadAnchor( + offset: Offset(0, 6), + childAlignment: Alignment.topRight, + overlayAlignment: Alignment.bottomRight, + ), + popover: (context) => SizedBox( + width: _newMenuWidth, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _MenuItem( + menu: _newController, + key: const ValueKey('library-new-strategy'), + icon: Icons.note_add_outlined, + label: 'New Strategy', + onPressed: widget.onCreateStrategy, + ), + _MenuItem( + menu: _newController, + key: const ValueKey('library-new-folder'), + icon: LucideIcons.folderPlus, + label: 'New Folder', + onPressed: widget.onCreateFolder, + ), + if (showLibraryTools) ...[ + const _MenuDivider(), + _MenuItem( + menu: _newController, + icon: Icons.file_download_outlined, + label: 'Import .ica', + onPressed: widget.onImportIca, + ), + _MenuItem( + menu: _newController, + icon: Icons.archive_outlined, + label: 'Import Backup', + onPressed: widget.onImportBackup, + ), + _MenuItem( + menu: _newController, + icon: Icons.backup_outlined, + label: 'Export Library', + onPressed: widget.onExportLibrary, + ), + ], + ], + ), + ), + child: ShadButton( + key: const ValueKey('library-new-menu'), + height: _controlHeight, + padding: const EdgeInsets.only(left: 8, right: 6), + onPressed: _newController.toggle, + leading: const Icon(Icons.add, size: 16), + trailing: const Icon(Icons.keyboard_arrow_down, size: 16), + child: const Text('New'), + ), + ); + } + + Widget _buildAccount() { + final auth = ref.watch(authProvider); + if (auth.isLoading) { + return const SizedBox( + key: ValueKey('library-account-action'), + width: _controlHeight, + height: _controlHeight, + child: Center( + child: SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ); + } + if (!auth.isAuthenticated) { + return Semantics( + label: 'Log in to Icarus', + button: true, + onTap: _showAuthDialog, + child: ShadButton.secondary( + key: const ValueKey('library-account-action'), + height: _controlHeight, + padding: const EdgeInsets.symmetric(horizontal: 10), + onPressed: _showAuthDialog, + child: const Text('Log In'), + ), + ); + } + return ShadPopover( + controller: _accountController, + padding: const EdgeInsets.all(6), + anchor: const ShadAnchor( + offset: Offset(0, 6), + childAlignment: Alignment.topRight, + overlayAlignment: Alignment.bottomRight, + ), + popover: (context) => SizedBox( + width: _accountMenuWidth, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(10, 6, 10, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + auth.displayName, + style: const TextStyle(fontWeight: FontWeight.w600), + overflow: TextOverflow.ellipsis, + ), + if (auth.user?.email case final email?) + Text( + email, + style: TextStyle( + fontSize: 12, + color: Settings.tacticalVioletTheme.mutedForeground, + ), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + const _MenuDivider(), + _MenuItem( + menu: _accountController, + icon: LucideIcons.logOut, + label: 'Sign Out', + onPressed: _confirmSignOut, + ), + ], + ), + ), + child: Semantics( + label: 'Account for ${auth.displayName}', + button: true, + onTap: _accountController.toggle, + child: ShadButton.ghost( + key: const ValueKey('library-account-action'), + width: _controlHeight, + height: _controlHeight, + padding: EdgeInsets.zero, + onPressed: _accountController.toggle, + child: AccountAvatar( + radius: 12, + backgroundColor: Settings.tacticalVioletTheme.secondary, + avatarUrl: auth.avatarUrl, + fallback: const Icon(Icons.person, size: 14), + ), + ), + ), + ); + } + + Future _confirmSignOut() async { + await ref.read(guardedSignOutRequestProvider)(context); + } +} + +class _TabButton extends StatelessWidget { + const _TabButton({ + super.key, + required this.icon, + required this.label, + required this.semanticsLabel, + required this.selected, + required this.onTap, + this.dimmed = false, + }); + + final IconData icon; + final String label; + final String semanticsLabel; + final bool selected; + final bool dimmed; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + const theme = Settings.tacticalVioletTheme; + final foreground = selected ? theme.foreground : theme.mutedForeground; + return Semantics( + label: semanticsLabel, + button: true, + selected: selected, + onTap: onTap, + child: Opacity( + opacity: dimmed ? 0.45 : 1, + child: ShadButton.ghost( + height: _controlHeight, + padding: const EdgeInsets.symmetric(horizontal: 10), + backgroundColor: selected ? theme.secondary : null, + foregroundColor: foreground, + hoverForegroundColor: theme.foreground, + onPressed: onTap, + leading: Icon(icon, size: 15), + child: Text( + label, + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500), + ), + ), + ), + ); + } +} + +class _MenuItem extends StatelessWidget { + const _MenuItem({ + super.key, + required this.menu, + required this.icon, + required this.label, + required this.onPressed, + }); + + /// The popover holding this item; closed before [onPressed] runs. + final ShadPopoverController menu; + final IconData? icon; + final String label; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return ShadButton.ghost( + height: 32, + mainAxisAlignment: MainAxisAlignment.start, + padding: const EdgeInsets.symmetric( + horizontal: _menuItemHorizontalPadding, + ), + gap: _menuItemGap, + onPressed: () { + menu.hide(); + onPressed(); + }, + leading: SizedBox( + width: _menuIconWidth, + child: icon == null ? null : Icon(icon, size: 16), + ), + child: Flexible( + child: Text( + label, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: Settings.tacticalVioletTheme.foreground), + ), + ), + ); + } +} + +class _MenuLabel extends StatelessWidget { + const _MenuLabel(this.text); + + final String text; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(_menuLabelLeftInset, 6, 8, 4), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + text, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 0.3, + color: Settings.tacticalVioletTheme.mutedForeground, + ), + ), + ), + ); + } +} + +class _MenuDivider extends StatelessWidget { + const _MenuDivider(); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Divider(height: 1, color: Settings.tacticalVioletTheme.border), + ); + } +} diff --git a/lib/widgets/strategy_quick_switcher.dart b/lib/widgets/strategy_quick_switcher.dart index 60991a67..b336f824 100644 --- a/lib/widgets/strategy_quick_switcher.dart +++ b/lib/widgets/strategy_quick_switcher.dart @@ -11,7 +11,6 @@ import 'package:icarus/const/settings.dart'; import 'package:icarus/providers/agent_filter_provider.dart'; import 'package:icarus/providers/interaction_state_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; -import 'package:icarus/strategy/strategy_models.dart'; import 'package:icarus/services/unsaved_strategy_guard.dart'; import 'package:icarus/widgets/overflow_tooltip_text.dart'; import 'package:icarus/widgets/text_editing_shortcut_scope.dart'; @@ -29,7 +28,7 @@ class StrategyQuickSwitcher extends ConsumerStatefulWidget { class _StrategyQuickSwitcherState extends ConsumerState { static const double _barWidth = 280; static const double _barHeight = 40; - static const EdgeInsets _displayMargin = EdgeInsets.all(16); + static const EdgeInsets _displayMargin = EdgeInsets.symmetric(horizontal: 16); final OverlayPortalController _controller = OverlayPortalController(); final LayerLink _layerLink = LayerLink(); late final TextEditingController _nameController; @@ -355,6 +354,7 @@ class _StrategyQuickSwitcherState extends ConsumerState { ); }, child: Container( + key: const ValueKey('strategy-quick-switcher-control'), width: _barWidth, height: _barHeight, decoration: BoxDecoration( diff --git a/lib/widgets/strategy_tile/strategy_tile.dart b/lib/widgets/strategy_tile/strategy_tile.dart index f5ea5cd6..7d7d143d 100644 --- a/lib/widgets/strategy_tile/strategy_tile.dart +++ b/lib/widgets/strategy_tile/strategy_tile.dart @@ -35,6 +35,7 @@ class StrategyTile extends ConsumerStatefulWidget { const StrategyTile.local({ super.key, required this.strategyData, + this.showDeviceBadge = false, }) : cloudStrategy = null, canRename = true, canDuplicate = true, @@ -48,9 +49,14 @@ class StrategyTile extends ConsumerStatefulWidget { required this.canDuplicate, required this.canDelete, required this.canMove, - }) : strategyData = null; + }) : strategyData = null, + showDeviceBadge = false; final StrategyData? strategyData; + + /// Marks a local strategy as not being in the cloud. Only meaningful while + /// the cloud is reachable; signed out, every strategy is on this device. + final bool showDeviceBadge; final CloudStrategyEntry? cloudStrategy; final bool canRename; final bool canDuplicate; @@ -303,6 +309,9 @@ class _StrategyTileState extends ConsumerState { child: StrategyTileThumbnail( assetPath: viewData.thumbnailAsset, borderRadius: strategyTileInnerRadius, + overlay: widget.showDeviceBadge + ? const DeviceOnlyBadge() + : null, ), ), const SizedBox(height: 10), diff --git a/lib/widgets/strategy_tile/strategy_tile_sections.dart b/lib/widgets/strategy_tile/strategy_tile_sections.dart index c7249660..4d934ba8 100644 --- a/lib/widgets/strategy_tile/strategy_tile_sections.dart +++ b/lib/widgets/strategy_tile/strategy_tile_sections.dart @@ -151,6 +151,7 @@ class StrategyTileThumbnail extends StatelessWidget { this.height, this.width, this.borderRadius = 16, + this.overlay, }); final String assetPath; @@ -158,6 +159,9 @@ class StrategyTileThumbnail extends StatelessWidget { final double? width; final double borderRadius; + /// Drawn over the bottom-left corner of the map, e.g. a sync badge. + final Widget? overlay; + @override Widget build(BuildContext context) { Widget image = Image.asset(assetPath, fit: BoxFit.cover); @@ -166,6 +170,15 @@ class StrategyTileThumbnail extends StatelessWidget { } else { image = SizedBox.expand(child: image); } + if (overlay != null) { + image = Stack( + fit: StackFit.passthrough, + children: [ + image, + Positioned(left: 8, bottom: 8, child: overlay!), + ], + ); + } return ClipRRect( borderRadius: BorderRadius.circular(borderRadius), @@ -174,6 +187,45 @@ class StrategyTileThumbnail extends StatelessWidget { } } +/// "On this device": the strategy exists only in the local library. +class DeviceOnlyBadge extends StatelessWidget { + const DeviceOnlyBadge({super.key}); + + @override + Widget build(BuildContext context) { + return Tooltip( + message: 'Saved only on this computer', + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Settings.tacticalVioletTheme.background.withValues(alpha: 0.85), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: Settings.tacticalVioletTheme.border), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + LucideIcons.monitor, + size: 12, + color: Settings.tacticalVioletTheme.foreground, + ), + const SizedBox(width: 5), + Text( + 'On this device', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Settings.tacticalVioletTheme.foreground, + ), + ), + ], + ), + ), + ); + } +} + class StrategyTileDetails extends StatelessWidget { const StrategyTileDetails({ super.key, diff --git a/lib/widgets/strategy_view_skeleton.dart b/lib/widgets/strategy_view_skeleton.dart index fd254a12..70a79428 100644 --- a/lib/widgets/strategy_view_skeleton.dart +++ b/lib/widgets/strategy_view_skeleton.dart @@ -3,6 +3,7 @@ import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/custom_icons.dart'; import 'package:icarus/const/maps.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/widgets/window_chrome.dart'; import 'package:icarus/widgets/dot_painter.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; @@ -156,12 +157,18 @@ class _SkeletonTopBar extends StatelessWidget { @override Widget build(BuildContext context) { final title = strategyName?.trim(); + return EditorWindowHeader(child: _buildBar(context, title)); + } + + Widget _buildBar(BuildContext context, String? title) { return Padding( - padding: const EdgeInsets.only(left: 15, top: 15, bottom: 10, right: 15), + padding: const EdgeInsets.symmetric(horizontal: 15), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, children: [ Row( + crossAxisAlignment: CrossAxisAlignment.center, children: [ const _SkeletonBlock(width: 40, height: 40, radius: 8), const SizedBox(width: 5), @@ -170,7 +177,7 @@ class _SkeletonTopBar extends StatelessWidget { ), Expanded( child: Padding( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.symmetric(horizontal: 16), child: Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 280), diff --git a/lib/widgets/window_chrome.dart b/lib/widgets/window_chrome.dart new file mode 100644 index 00000000..dc9b3c7e --- /dev/null +++ b/lib/widgets/window_chrome.dart @@ -0,0 +1,284 @@ +import 'package:flutter/foundation.dart' + show TargetPlatform, defaultTargetPlatform, kIsWeb; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:window_manager/window_manager.dart'; + +/// Height of the strip the app draws in place of the native title bar. +/// `macos/Runner/MainFlutterWindow.swift` centers the traffic lights on it. +const double kWindowStripHeight = 40; + +/// Height of the editor's header band: the 65px map card. +const double kEditorHeaderHeight = 65; + +/// Room reserved on the left for the native macOS traffic lights. +const double kMacTrafficLightInset = 78; + +/// Talks to `MainFlutterWindow.swift`, which centers the traffic lights on +/// whatever band height the current screen reports. +const MethodChannel _chromeChannel = MethodChannel('icarus/window_chrome'); + +int _editorHeadersMounted = 0; + +Future _syncMacTitleStripHeight() async { + if (!_isMacOS) return; + final height = + _editorHeadersMounted > 0 ? kEditorHeaderHeight : kWindowStripHeight; + try { + await _chromeChannel.invokeMethod('setTitleStripHeight', height); + } on MissingPluginException { + // Running without the macOS runner (tests, other hosts). + } +} + +bool get _isMacOS => !kIsWeb && defaultTargetPlatform == TargetPlatform.macOS; +bool get _isWindows => + !kIsWeb && defaultTargetPlatform == TargetPlatform.windows; + +bool get _drawsCaptionButtons => + _isWindows || (!kIsWeb && defaultTargetPlatform == TargetPlatform.linux); + +/// True on desktop builds, where the native title bar is hidden and the app +/// owns that space. +bool get hasCustomWindowChrome => _isMacOS || _drawsCaptionButtons; + +/// Lets the user drag the window by [child], and double-click it to zoom, on +/// desktop. Elsewhere it is transparent. +class WindowDragArea extends StatelessWidget { + const WindowDragArea({super.key, required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + if (!hasCustomWindowChrome) { + return child; + } + return DragToMoveArea(child: child); + } +} + +/// The editor's header: controls and the map card centered on one band, +/// with the traffic lights (macOS) or caption buttons (Windows, Linux) on +/// that same line. On macOS the window is told the band's height so the +/// lights move down to meet it, and back up when the editor closes. Windows +/// and Linux split the canvas gap evenly above and below the band. +class EditorWindowHeader extends StatefulWidget { + const EditorWindowHeader({super.key, required this.child}); + + final Widget child; + + @override + State createState() => _EditorWindowHeaderState(); +} + +class _EditorWindowHeaderState extends State { + @override + void initState() { + super.initState(); + _editorHeadersMounted++; + _syncMacTitleStripHeight(); + } + + @override + void dispose() { + // The skeleton and the real header swap within one frame, so count + // mounts instead of assuming this was the last one. + _editorHeadersMounted--; + _syncMacTitleStripHeight(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return WindowDragArea( + child: Padding( + padding: _drawsCaptionButtons + ? const EdgeInsets.symmetric(vertical: 5) + : const EdgeInsets.only(bottom: 10), + child: SizedBox( + height: kEditorHeaderHeight, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const MacTrafficLightInset(), + Expanded(child: widget.child), + const WindowCaptionButtons(), + ], + ), + ), + ), + ); + } +} + +/// The compact Icarus lockup at the start of the Windows library strip. +class WindowsIcarusWordmark extends StatelessWidget { + const WindowsIcarusWordmark({super.key}); + + @override + Widget build(BuildContext context) { + if (!_isWindows) { + return const SizedBox.shrink(); + } + return Padding( + padding: const EdgeInsets.only(left: 10, right: 4), + child: SvgPicture.asset( + 'assets/brand/icarus-wordmark.svg', + height: 14, + semanticsLabel: 'Icarus', + ), + ); + } +} + +/// Blank space where the macOS traffic lights sit. Collapses in full screen, +/// where macOS hides them, and on every other platform. +class MacTrafficLightInset extends StatefulWidget { + const MacTrafficLightInset({super.key}); + + @override + State createState() => _MacTrafficLightInsetState(); +} + +class _MacTrafficLightInsetState extends State + with WindowListener { + bool _fullScreen = false; + + @override + void initState() { + super.initState(); + if (!_isMacOS) return; + windowManager.addListener(this); + windowManager.isFullScreen().then((value) { + if (mounted && value != _fullScreen) { + setState(() => _fullScreen = value); + } + }); + } + + @override + void dispose() { + if (_isMacOS) { + windowManager.removeListener(this); + } + super.dispose(); + } + + @override + void onWindowEnterFullScreen() => setState(() => _fullScreen = true); + + @override + void onWindowLeaveFullScreen() => setState(() => _fullScreen = false); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: _isMacOS && !_fullScreen ? kMacTrafficLightInset : 0); + } +} + +/// Minimize, maximize, and close for platforms whose native buttons went +/// away with the title bar. Empty on macOS and web. +class WindowCaptionButtons extends StatefulWidget { + const WindowCaptionButtons({super.key}); + + @override + State createState() => _WindowCaptionButtonsState(); +} + +class _WindowCaptionButtonsState extends State + with WindowListener { + bool _maximized = false; + + @override + void initState() { + super.initState(); + if (!_drawsCaptionButtons) return; + windowManager.addListener(this); + windowManager.isMaximized().then((value) { + if (mounted && value != _maximized) { + setState(() => _maximized = value); + } + }); + } + + @override + void dispose() { + if (_drawsCaptionButtons) { + windowManager.removeListener(this); + } + super.dispose(); + } + + @override + void onWindowMaximize() => setState(() => _maximized = true); + + @override + void onWindowUnmaximize() => setState(() => _maximized = false); + + @override + Widget build(BuildContext context) { + if (!_drawsCaptionButtons) { + return const SizedBox.shrink(); + } + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + WindowCaptionButton.minimize( + brightness: Brightness.dark, + onPressed: windowManager.minimize, + ), + if (_maximized) + WindowCaptionButton.unmaximize( + brightness: Brightness.dark, + onPressed: windowManager.unmaximize, + ) + else + WindowCaptionButton.maximize( + brightness: Brightness.dark, + onPressed: windowManager.maximize, + ), + // Goes through window_manager so the editor's unsaved-changes guard + // (setPreventClose) still runs. + WindowCaptionButton.close( + brightness: Brightness.dark, + onPressed: windowManager.close, + ), + ], + ); + } +} + +/// The frame that stands in for the native title bar. The screen places a +/// [WindowDragArea] only in its empty space so controls receive taps without +/// waiting for the title bar's double-click gesture. +class AppWindowStrip extends StatelessWidget { + const AppWindowStrip({super.key, required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + return Container( + key: const ValueKey('app-window-strip'), + height: kWindowStripHeight, + decoration: BoxDecoration( + color: Settings.tacticalVioletTheme.card, + border: Border( + bottom: BorderSide(color: Settings.tacticalVioletTheme.border), + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const MacTrafficLightInset(), + Expanded(child: child), + const WindowCaptionButtons(), + ], + ), + ); + } +} diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift index 3cc05eb2..245f4504 100644 --- a/macos/Runner/MainFlutterWindow.swift +++ b/macos/Runner/MainFlutterWindow.swift @@ -1,15 +1,108 @@ import Cocoa import FlutterMacOS +/// Icarus draws its own title strip, so the window keeps only the native +/// traffic lights and lets the Flutter view extend under the title bar. class MainFlutterWindow: NSWindow { + /// Height of the band the traffic lights are centered on. Starts at the + /// library strip's height (`kWindowStripHeight` in + /// lib/widgets/window_chrome.dart); screens with a taller header, like the + /// editor, update it over the `icarus/window_chrome` channel. + private var titleStripHeight: CGFloat = 40 + private var layoutObservers: [NSObjectProtocol] = [] + private var chromeChannel: FlutterMethodChannel? + override func awakeFromNib() { let flutterViewController = FlutterViewController() let windowFrame = self.frame self.contentViewController = flutterViewController self.setFrame(windowFrame, display: true) + titleVisibility = .hidden + titlebarAppearsTransparent = true + styleMask.insert(.fullSizeContentView) + RegisterGeneratedPlugins(registry: flutterViewController) + let channel = FlutterMethodChannel( + name: "icarus/window_chrome", + binaryMessenger: flutterViewController.engine.binaryMessenger + ) + channel.setMethodCallHandler { [weak self] call, result in + guard let self = self else { return } + switch call.method { + case "setTitleStripHeight": + guard let height = call.arguments as? Double else { + result(FlutterError(code: "bad-args", message: "height missing", details: nil)) + return + } + self.titleStripHeight = CGFloat(height) + self.centerTrafficLights() + result(nil) + default: + result(FlutterMethodNotImplemented) + } + } + chromeChannel = channel + super.awakeFromNib() + + observeTitleBarLayout() + centerTrafficLights() + } + + deinit { + for observer in layoutObservers { + NotificationCenter.default.removeObserver(observer) + } + } + + override func layoutIfNeeded() { + super.layoutIfNeeded() + centerTrafficLights() + } + + private func observeTitleBarLayout() { + let names: [Notification.Name] = [ + NSWindow.didResizeNotification, + NSWindow.didExitFullScreenNotification, + NSWindow.didBecomeKeyNotification, + NSWindow.didResignKeyNotification, + ] + for name in names { + let observer = NotificationCenter.default.addObserver( + forName: name, object: self, queue: .main + ) { [weak self] _ in + self?.centerTrafficLights() + } + layoutObservers.append(observer) + } + } + + /// AppKit lays the traffic lights out for its own 28pt title bar. Grow the + /// title bar container to the strip's height and re-center the buttons in + /// it, so they line up with the app's controls. + private func centerTrafficLights() { + if styleMask.contains(.fullScreen) { return } + guard + let closeButton = standardWindowButton(.closeButton), + let titleBarView = closeButton.superview, + let container = titleBarView.superview + else { return } + + var containerFrame = container.frame + if containerFrame.height != titleStripHeight { + containerFrame.origin.y = frame.height - titleStripHeight + containerFrame.size.height = titleStripHeight + container.frame = containerFrame + } + + let buttons: [NSWindow.ButtonType] = [.closeButton, .miniaturizeButton, .zoomButton] + for type in buttons { + guard let button = standardWindowButton(type) else { continue } + var origin = button.frame.origin + origin.y = (titleBarView.frame.height - button.frame.height) / 2 + button.setFrameOrigin(origin) + } } } diff --git a/pubspec.yaml b/pubspec.yaml index c377cada..5e648383 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -95,6 +95,7 @@ flutter: - asset: assets/fonts/CustomIcons.ttf assets: - assets/ + - assets/brand/ - assets/maps/ - assets/maps/thumbnails/ - assets/agents/ diff --git a/test/library_entries_test.dart b/test/library_entries_test.dart new file mode 100644 index 00000000..3adacd3e --- /dev/null +++ b/test/library_entries_test.dart @@ -0,0 +1,113 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/collab/cloud_library_models.dart'; +import 'package:icarus/const/maps.dart'; +import 'package:icarus/providers/folder_provider.dart'; +import 'package:icarus/providers/library_workspace_provider.dart'; +import 'package:icarus/providers/strategy_filter_provider.dart'; +import 'package:icarus/strategy/strategy_models.dart'; +import 'package:icarus/widgets/library_entries.dart'; + +void main() { + test('a strategy present in both stores shows once, as its cloud copy', () { + final local = LibraryStrategyRow.local( + _localStrategy('shared-id', 'Local copy'), + showDeviceBadge: true, + ); + final onlyLocal = LibraryStrategyRow.local( + _localStrategy('local-only', 'Mine'), + showDeviceBadge: true, + ); + final cloud = LibraryStrategyRow.cloud(_cloudStrategy('shared-id', 'Cloud')); + + final merged = mergeLibraryStrategies( + local: [local, onlyLocal], + cloud: [cloud], + ); + + expect(merged.map((row) => row.id), ['shared-id', 'local-only']); + expect(merged.first.cloud, isNotNull); + expect(merged.last.showDeviceBadge, isTrue); + }); + + test('folders keep their store after merging', () { + final merged = mergeLibraryFolders( + local: [_folderRow('a', LibraryWorkspace.local)], + cloud: [_folderRow('b', LibraryWorkspace.cloud)], + ); + + expect( + merged.map((row) => row.store), + [LibraryWorkspace.cloud, LibraryWorkspace.local], + ); + }); + + test('sorting mixes both stores on the chosen field', () { + final rows = [ + LibraryStrategyRow.local( + _localStrategy('b', 'Bravo', created: DateTime(2024, 1, 2)), + showDeviceBadge: false, + ), + LibraryStrategyRow.cloud( + _cloudStrategy('a', 'Alpha', created: DateTime(2024, 1, 3)), + ), + LibraryStrategyRow.local( + _localStrategy('c', 'Charlie', created: DateTime(2024, 1, 1)), + showDeviceBadge: false, + ), + ]; + + final byName = sortLibraryStrategies( + rows, + StrategyFilterState( + sortBy: SortBy.alphabetical, + sortOrder: SortOrder.ascending, + ), + ); + expect(byName.map((row) => row.id), ['a', 'b', 'c']); + + final newestFirst = sortLibraryStrategies( + rows, + StrategyFilterState( + sortBy: SortBy.dateCreated, + sortOrder: SortOrder.descending, + ), + ); + expect(newestFirst.map((row) => row.id), ['a', 'b', 'c']); + }); +} + +StrategyData _localStrategy(String id, String name, {DateTime? created}) { + final at = created ?? DateTime(2024, 1, 1); + return StrategyData( + id: id, + name: name, + mapData: MapValue.ascent, + versionNumber: 1, + folderID: null, + pages: const [], + createdAt: at, + lastEdited: at, + ); +} + +CloudStrategyEntry _cloudStrategy(String id, String name, {DateTime? created}) { + return ( + strategy: _localStrategy(id, name, created: created), + revision: 1, + role: 'owner', + attackLabel: 'Attack', + ); +} + +LibraryFolderRow _folderRow(String id, LibraryWorkspace store) { + return LibraryFolderRow( + folder: Folder( + name: id, + id: id, + dateCreated: DateTime(2024, 1, 1), + color: FolderColor.blue, + ), + store: store, + lastUpdated: DateTime(2024, 1, 1), + ); +} diff --git a/test/library_navigation_provider_test.dart b/test/library_navigation_provider_test.dart new file mode 100644 index 00000000..ef9cca7c --- /dev/null +++ b/test/library_navigation_provider_test.dart @@ -0,0 +1,90 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/providers/library_navigation_provider.dart'; +import 'package:icarus/providers/library_workspace_provider.dart'; + +void main() { + test('My Library uses the cloud store when it is reachable', () { + final container = _container(_cloudReadyState); + + container.read(libraryNavigationProvider).showLibrary(); + + expect(container.read(libraryTabProvider), LibraryTab.library); + expect(container.read(libraryWorkspaceProvider), LibraryWorkspace.cloud); + }); + + test('My Library falls back to the local store when signed out', () { + final container = _container(_signedOutState); + + container.read(libraryNavigationProvider).showLibrary(); + + expect(container.read(libraryTabProvider), LibraryTab.library); + expect(container.read(libraryWorkspaceProvider), LibraryWorkspace.local); + }); + + test('Shared needs the cloud and reports when it is not there', () { + final container = _container(_signedOutState); + + final opened = container.read(libraryNavigationProvider).showShared(); + + expect(opened, isFalse); + expect(container.read(libraryTabProvider), LibraryTab.library); + }); + + test('Shared maps to the cloud shared section', () { + final container = _container(_cloudReadyState); + + final opened = container.read(libraryNavigationProvider).showShared(); + + expect(opened, isTrue); + expect(container.read(libraryTabProvider), LibraryTab.shared); + expect( + container.read(cloudLibrarySectionProvider), + CloudLibrarySection.sharedWithMe, + ); + }); + + test('Community is its own tab', () { + final container = _container(_cloudReadyState); + + container.read(libraryNavigationProvider).showCommunity(); + + expect(container.read(libraryTabProvider), LibraryTab.community); + }); +} + +ProviderContainer _container(AppAuthState auth) { + final container = ProviderContainer( + overrides: [ + authProvider.overrideWith(() => _FakeAuthProvider(auth)), + ], + ); + addTearDown(container.dispose); + return container; +} + +const _signedOutState = AppAuthState( + isLoading: false, + isAuthenticated: false, + isConvexUserReady: false, + convexAuthStatus: ConvexAuthStatus.signedOut, + user: null, +); + +const _cloudReadyState = AppAuthState( + isLoading: false, + isAuthenticated: true, + isConvexUserReady: true, + convexAuthStatus: ConvexAuthStatus.ready, + user: null, +); + +class _FakeAuthProvider extends AuthProvider { + _FakeAuthProvider(this._initial); + + final AppAuthState _initial; + + @override + AppAuthState build() => _initial; +} diff --git a/test/providers/cloud_library_action_providers_test.dart b/test/providers/cloud_library_action_providers_test.dart index 8a05f09a..8796db43 100644 --- a/test/providers/cloud_library_action_providers_test.dart +++ b/test/providers/cloud_library_action_providers_test.dart @@ -320,7 +320,7 @@ class _Harness { ), cloudFoldersProvider.overrideWith((_) { cloudFolderBuilds += 1; - return Stream.value(const []); + return const AsyncData([]); }), cloudAllFoldersProvider.overrideWith((_) { allCloudFolderBuilds += 1; diff --git a/test/providers/folder_provider_test.dart b/test/providers/folder_provider_test.dart index 7ec217ce..430b2235 100644 --- a/test/providers/folder_provider_test.dart +++ b/test/providers/folder_provider_test.dart @@ -56,7 +56,7 @@ ProviderContainer _createContainer(ConvexStrategyRepository repository) { pinnedItemsProvider.overrideWith(_MemoryPinnedItemsProvider.new), convexStrategyRepositoryProvider.overrideWithValue(repository), authProvider.overrideWith(_ReadyAuthProvider.new), - cloudFoldersProvider.overrideWith((_) => Stream.value(const [])), + cloudFoldersProvider.overrideWith((_) => const AsyncData([])), cloudAllFoldersProvider.overrideWith((_) => Stream.value(const [])), cloudStrategiesProvider.overrideWith((_) => Stream.value(const [])), ], diff --git a/test/strategy_view_skeleton_test.dart b/test/strategy_view_skeleton_test.dart index c2b800f7..2d724b89 100644 --- a/test/strategy_view_skeleton_test.dart +++ b/test/strategy_view_skeleton_test.dart @@ -1,35 +1,48 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/widgets/strategy_view_skeleton.dart'; +import 'package:icarus/widgets/window_chrome.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; void main() { testWidgets('loading skeleton fits the minimum desktop window', (tester) async { - await tester.binding.setSurfaceSize(const Size(800, 630)); - addTearDown(() => tester.binding.setSurfaceSize(null)); + debugDefaultTargetPlatformOverride = TargetPlatform.windows; + try { + await tester.binding.setSurfaceSize(const Size(800, 630)); + addTearDown(() => tester.binding.setSurfaceSize(null)); - await tester.pumpWidget( - ShadApp( - themeMode: ThemeMode.dark, - darkTheme: ShadThemeData( - brightness: Brightness.dark, - colorScheme: Settings.tacticalVioletTheme, - ), - home: const MediaQuery( - data: MediaQueryData( - size: Size(800, 630), - disableAnimations: true, + await tester.pumpWidget( + ShadApp( + themeMode: ThemeMode.dark, + darkTheme: ShadThemeData( + brightness: Brightness.dark, + colorScheme: Settings.tacticalVioletTheme, ), - child: StrategyViewSkeleton( - strategyName: 'SYNC BOUNDARY PROBE', + home: const MediaQuery( + data: MediaQueryData( + size: Size(800, 630), + disableAnimations: true, + ), + child: StrategyViewSkeleton( + strategyName: 'SYNC BOUNDARY PROBE', + ), ), ), - ), - ); - await tester.pump(); + ); + await tester.pump(); + } finally { + debugDefaultTargetPlatformOverride = null; + } + + final mapThumbnail = tester.getRect(find.byType(Image).first); + final strategyTitle = tester.getRect(find.text('SYNC BOUNDARY PROBE')); + final captionButtons = tester.getRect(find.byType(WindowCaptionButtons)); + expect(strategyTitle.center.dy, mapThumbnail.center.dy); + expect(captionButtons.center.dy, mapThumbnail.center.dy); expect(tester.takeException(), isNull); }); } diff --git a/test/widgets/cloud_beta_automation_semantics_test.dart b/test/widgets/cloud_beta_automation_semantics_test.dart index c82daaa8..da75ed68 100644 --- a/test/widgets/cloud_beta_automation_semantics_test.dart +++ b/test/widgets/cloud_beta_automation_semantics_test.dart @@ -8,7 +8,7 @@ import 'package:icarus/providers/auth_provider.dart'; import 'package:icarus/services/guarded_sign_out.dart'; import 'package:icarus/widgets/custom_text_field.dart'; import 'package:icarus/widgets/dialogs/auth/auth_dialog.dart'; -import 'package:icarus/widgets/folder_navigator.dart'; +import 'package:icarus/widgets/library_title_strip.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; @@ -105,62 +105,58 @@ void main() { semanticsHandle.dispose(); }); - testWidgets('library rail exposes stable destinations while signed out', + testWidgets('library strip exposes stable destinations while signed out', (tester) async { - await tester.pumpWidget( - _testApp( - const SizedBox( - width: 220, - height: 800, - child: LibraryNavigationRail(), - ), - ), - ); + await tester.pumpWidget(_testApp(_strip())); - expect(find.byKey(const ValueKey('library-local')), findsOneWidget); - expect(find.byKey(const ValueKey('library-cloud')), findsOneWidget); - expect(find.byKey(const ValueKey('library-shared')), findsOneWidget); - expect(find.byKey(const ValueKey('library-community')), findsOneWidget); + expect(find.byKey(const ValueKey('library-tab-library')), findsOneWidget); + expect(find.byKey(const ValueKey('library-tab-shared')), findsOneWidget); + expect( + find.byKey(const ValueKey('library-tab-community')), + findsOneWidget, + ); expect( find.byKey(const ValueKey('library-account-action')), findsOneWidget, ); - expect(_semanticsLabel('This Computer library'), findsOneWidget); - expect(_semanticsLabel('Cloud library'), findsOneWidget); + expect(find.byKey(const ValueKey('library-new-menu')), findsOneWidget); + expect(find.byKey(const ValueKey('library-sort-menu')), findsOneWidget); + expect(_semanticsLabel('My Library'), findsOneWidget); expect(_semanticsLabel('Shared library'), findsOneWidget); expect(_semanticsLabel('Community library'), findsOneWidget); expect(_semanticsLabel('Log in to Icarus'), findsOneWidget); - expect(_semantics('This Computer library').properties.onTap, isNotNull); + expect(_semantics('My Library').properties.onTap, isNotNull); 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(), - ), - ), - ); + testWidgets('signed-out Shared tab opens login', (tester) async { + await tester.pumpWidget(_testApp(_strip())); - await tester.tap(find.byKey(const ValueKey('library-cloud'))); + await tester.tap(find.byKey(const ValueKey('library-tab-shared'))); await tester.pumpAndSettle(); expect(find.byType(AuthDialog), findsOneWidget); expect(find.text('Sign in'), findsAtLeastNWidgets(1)); + }); - Navigator.of(tester.element(find.byType(AuthDialog))).pop(); + testWidgets('New menu offers a strategy and a folder', (tester) async { + var created = 0; + await tester.pumpWidget( + _testApp(_strip(onCreateStrategy: () => created++)), + ); + + await tester.tap(find.byKey(const ValueKey('library-new-menu'))); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const ValueKey('library-shared'))); + expect(find.byKey(const ValueKey('library-new-strategy')), findsOneWidget); + expect(find.byKey(const ValueKey('library-new-folder')), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('library-new-strategy'))); await tester.pumpAndSettle(); - expect(find.byType(AuthDialog), findsOneWidget); + expect(created, 1); }); - testWidgets('library account action uses guarded sign out', (tester) async { var requests = 0; await tester.pumpWidget( @@ -172,24 +168,32 @@ void main() { return true; }), ], - child: const ShadApp( - home: Scaffold( - body: SizedBox( - width: 220, - height: 800, - child: LibraryNavigationRail(), - ), - ), - ), + child: ShadApp(home: Scaffold(body: _strip())), ), ); await tester.tap(find.byKey(const ValueKey('library-account-action'))); - await tester.pump(); + await tester.pumpAndSettle(); + await tester.tap(find.text('Sign Out')); + await tester.pumpAndSettle(); expect(requests, 1); }); } +Widget _strip({VoidCallback? onCreateStrategy}) { + return SizedBox( + width: 1200, + height: 40, + child: LibraryTitleStrip( + onCreateStrategy: onCreateStrategy ?? () {}, + onCreateFolder: () {}, + onImportIca: () {}, + onImportBackup: () {}, + onExportLibrary: () {}, + ), + ); +} + Semantics _semantics(String label) { return _semanticsLabel(label).evaluate().single.widget as Semantics; } diff --git a/test/widgets/cloud_library_empty_states_test.dart b/test/widgets/cloud_library_empty_states_test.dart index 2d3a486f..13ce56be 100644 --- a/test/widgets/cloud_library_empty_states_test.dart +++ b/test/widgets/cloud_library_empty_states_test.dart @@ -1,4 +1,10 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; +import 'package:hive_ce_flutter/adapters.dart'; +import 'package:icarus/const/hive_boxes.dart'; +import 'package:icarus/providers/folder_provider.dart'; +import 'package:icarus/strategy/strategy_models.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:icarus/collab/cloud_library_models.dart'; @@ -12,11 +18,25 @@ import 'package:icarus/widgets/text_editing_shortcut_scope.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; void main() { - setUp(() { + late Directory tempDir; + + setUp(() async { CoordinateSystem(playAreaSize: const Size(1280, 720)); + tempDir = await Directory.systemTemp.createTemp('icarus-library-'); + Hive.init(tempDir.path); + // My Library lists the local store next to the cloud one, so the widget + // reads these boxes even in cloud mode. + await Hive.openBox(HiveBoxNames.strategiesBox); + await Hive.openBox(HiveBoxNames.foldersBox); + await Hive.openBox(HiveBoxNames.pinnedItemsBox); + }); + + tearDown(() async { + await Hive.close(); + await tempDir.delete(recursive: true); }); - testWidgets('empty Cloud leads to creating a cloud strategy', (tester) async { + testWidgets('empty library leads to creating a strategy', (tester) async { var createCount = 0; await tester.pumpWidget( _cloudApp( @@ -25,15 +45,15 @@ void main() { ); 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('library-empty-state')), findsOneWidget); + expect(find.text('Your library is empty'), findsOneWidget); expect( - find.byKey(const ValueKey('cloud-empty-create-strategy')), + find.byKey(const ValueKey('library-empty-create-strategy')), findsOneWidget, ); await tester.tap( - find.byKey(const ValueKey('cloud-empty-create-strategy')), + find.byKey(const ValueKey('library-empty-create-strategy')), ); expect(createCount, 1); }); @@ -79,7 +99,7 @@ Widget _cloudApp( cloudLibrarySectionProvider.overrideWith( () => _CloudSectionNotifier(section), ), - cloudFoldersProvider.overrideWith( + cloudFolderTreeProvider.overrideWith( (_) => Stream.value(const []), ), cloudStrategiesProvider.overrideWith( diff --git a/test/widgets/strategy_quick_switcher_layout_test.dart b/test/widgets/strategy_quick_switcher_layout_test.dart new file mode 100644 index 00000000..ab520206 --- /dev/null +++ b/test/widgets/strategy_quick_switcher_layout_test.dart @@ -0,0 +1,102 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive_ce/hive.dart'; +import 'package:icarus/const/hive_boxes.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; +import 'package:icarus/widgets/strategy_quick_switcher.dart'; +import 'package:icarus/widgets/window_chrome.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +void main() { + late Directory hiveDirectory; + + setUpAll(() async { + hiveDirectory = await Directory.systemTemp.createTemp( + 'icarus-quick-switcher-layout-', + ); + Hive.init(hiveDirectory.path); + await Hive.openBox(HiveBoxNames.strategiesBox); + }); + + tearDownAll(() async { + await Hive.close(); + await hiveDirectory.delete(recursive: true); + }); + + testWidgets('editor controls share the map card center line', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.windows; + try { + await tester.binding.setSurfaceSize(const Size(800, 160)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + strategyProvider.overrideWith(_OpenStrategyProvider.new), + ], + child: ShadApp( + themeMode: ThemeMode.dark, + darkTheme: ShadThemeData( + brightness: Brightness.dark, + colorScheme: Settings.tacticalVioletTheme, + ), + home: const Scaffold( + body: Align( + alignment: Alignment.topCenter, + child: EditorWindowHeader( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + SizedBox( + key: ValueKey('map-card-reference'), + width: 262, + height: 65, + ), + StrategyQuickSwitcher(), + ], + ), + ), + ), + ), + ), + ), + ); + await tester.pump(); + } finally { + debugDefaultTargetPlatformOverride = null; + } + + final control = tester.getRect( + find.byKey(const ValueKey('strategy-quick-switcher-control')), + ); + final mapCard = tester.getRect( + find.byKey(const ValueKey('map-card-reference')), + ); + final captionButtons = tester.getRect(find.byType(WindowCaptionButtons)); + final header = tester.getRect(find.byType(EditorWindowHeader)); + + expect(control.center.dy, mapCard.center.dy); + expect(captionButtons.center.dy, mapCard.center.dy); + expect(mapCard.top - header.top, header.bottom - mapCard.bottom); + expect(control.height, 40); + expect(tester.takeException(), isNull); + }); +} + +class _OpenStrategyProvider extends StrategyProvider { + @override + StrategyState build() { + return const StrategyState( + strategyId: 'strategy-1', + strategyName: 'afeaf', + source: StrategySource.local, + isOpen: true, + ); + } +}