From ca870cf793f809862263c03fa9dc3d0085e990e8 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Thu, 3 Sep 2026 22:01:53 -0400 Subject: [PATCH 1/8] feat: replace the library rail with a title-strip and one library The library screen's only chrome is now a 40px strip drawn in place of the native title bar: My Library / Shared / Community tabs on the left; search, sort, New, and the account on the right. The hover rail, the 70px app bar, and the never-mounted folder sidebar are gone. "This Computer" is no longer a destination. My Library shows the local and cloud stores together at the root, deduplicated by id; local strategies carry an "On this device" badge while the cloud is reachable. Opening a folder makes its store active, so every existing per-store path (create, move, edit, delete) keeps working unchanged. The breadcrumb moved into the content area and only appears inside a folder. Window chrome: window_manager hides the native title bar on desktop. macOS keeps its traffic lights, re-centered on the strip by MainFlutterWindow; Windows and Linux get app-drawn caption buttons. The editor's top row and skeleton double as the drag handle. Co-Authored-By: Claude Fable 5.1 --- CONTEXT.md | 8 + DESIGN.md | 10 + TODO.md | 13 +- docs/mockups/library-nav-alternatives.html | 426 ++++++++ .../collab/remote_library_provider.dart | 52 +- lib/providers/folder_provider.dart | 12 +- .../library_navigation_provider.dart | 52 + .../library_rail_hover_provider.dart | 3 - lib/providers/library_workspace_provider.dart | 23 + lib/providers/strategy_provider.dart | 2 +- lib/services/desktop_runtime_native.dart | 5 + lib/strategy_view.dart | 18 +- lib/widgets/current_path_bar.dart | 166 --- .../strategy/create_strategy_dialog.dart | 2 +- lib/widgets/folder_card.dart | 3 +- lib/widgets/folder_content.dart | 652 ++++++------ lib/widgets/folder_edit_dialog.dart | 7 + lib/widgets/folder_navigator.dart | 822 ++------------- lib/widgets/folder_navigator_sidebar.dart | 971 ------------------ lib/widgets/folder_pill.dart | 28 +- lib/widgets/library_breadcrumb.dart | 160 +++ lib/widgets/library_entries.dart | 112 ++ lib/widgets/library_title_strip.dart | 501 +++++++++ lib/widgets/strategy_tile/strategy_tile.dart | 11 +- .../strategy_tile/strategy_tile_sections.dart | 52 + lib/widgets/strategy_view_skeleton.dart | 14 + lib/widgets/window_chrome.dart | 187 ++++ macos/Runner/MainFlutterWindow.swift | 68 ++ test/library_entries_test.dart | 113 ++ test/library_navigation_provider_test.dart | 90 ++ .../cloud_beta_automation_semantics_test.dart | 75 +- .../cloud_library_empty_states_test.dart | 34 +- 32 files changed, 2385 insertions(+), 2307 deletions(-) create mode 100644 docs/mockups/library-nav-alternatives.html create mode 100644 lib/providers/library_navigation_provider.dart delete mode 100644 lib/providers/library_rail_hover_provider.dart delete mode 100644 lib/widgets/current_path_bar.dart delete mode 100644 lib/widgets/folder_navigator_sidebar.dart create mode 100644 lib/widgets/library_breadcrumb.dart create mode 100644 lib/widgets/library_entries.dart create mode 100644 lib/widgets/library_title_strip.dart create mode 100644 lib/widgets/window_chrome.dart create mode 100644 test/library_entries_test.dart create mode 100644 test/library_navigation_provider_test.dart 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/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 1ef5f6ac..0cc951eb 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 1de2c408..c71eee30 100644 --- a/lib/providers/folder_provider.dart +++ b/lib/providers/folder_provider.dart @@ -58,7 +58,7 @@ class FolderProvider extends Notifier { color: color.name, customColorValue: customColor?.toARGB32(), ); - ref.invalidate(cloudFoldersProvider); + ref.invalidate(cloudFolderTreeProvider); return newFolder; } catch (error, stackTrace) { await _maybeReportCloudUnauthenticated( @@ -86,6 +86,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); } @@ -215,8 +222,7 @@ class FolderProvider extends Notifier { customColorValue: newCustomColor?.toARGB32(), clearCustomColorValue: newCustomColor == null, ); - ref.invalidate(cloudFoldersProvider); - ref.invalidate(cloudAllFoldersProvider); + ref.invalidate(cloudFolderTreeProvider); } catch (error, stackTrace) { await _maybeReportCloudUnauthenticated( source: 'folder:update', 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 4723c9c7..50939aed 100644 --- a/lib/providers/strategy_provider.dart +++ b/lib/providers/strategy_provider.dart @@ -1153,7 +1153,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 fd53575e..cd0d631c 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'; @@ -29,6 +28,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 { @@ -178,7 +178,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(); @@ -218,7 +217,15 @@ class _StrategyViewState extends ConsumerState return Scaffold( body: Column( children: [ - Padding( + // The native title bar is hidden (see window_chrome.dart), so the + // editor's top row doubles as the window's drag handle. + WindowDragArea( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const MacTrafficLightInset(), + Expanded( + child: Padding( padding: const EdgeInsets.only(left: 15, top: 15, bottom: 10, right: 15), child: LayoutBuilder( @@ -278,6 +285,11 @@ class _StrategyViewState extends ConsumerState ], ); }, + ), + ), + ), + const WindowCaptionButtons(), + ], ), ), const Expanded( diff --git a/lib/widgets/current_path_bar.dart b/lib/widgets/current_path_bar.dart deleted file mode 100644 index 2b73f039..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) { - final item = details.data; - if (item is StrategyItem) { - ref.read(strategyProvider.notifier).moveToFolder( - strategyID: item.strategyId, - parentID: folder?.id, - source: item.strategy == null - ? StrategySource.cloud - : StrategySource.local, - ); - } else if (item is FolderItem) { - 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 b64ed0b0..d49d00eb 100644 --- a/lib/widgets/folder_card.dart +++ b/lib/widgets/folder_card.dart @@ -5,6 +5,7 @@ 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_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'; @@ -313,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; 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 6753449e..b04611c3 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/widgets/better_color_picker.dart'; import 'package:icarus/widgets/color_picker_button.dart'; import 'package:icarus/widgets/custom_segmented_tabs.dart'; @@ -23,8 +24,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(); @@ -85,6 +90,7 @@ class _FolderEditDialogState extends ConsumerState { newIconId: _selectedIconId, newColor: _selectedColor, newCustomColor: _customColor, + workspace: widget.store, ); if (context.mounted) Navigator.of(context).pop(); return; @@ -97,6 +103,7 @@ class _FolderEditDialogState extends ConsumerState { iconId: _selectedIconId, color: _selectedColor, customColor: _customColor, + workspace: widget.store, ); } catch (_) { // Cloud folder creation failed (already toasted by the diff --git a/lib/widgets/folder_navigator.dart b/lib/widgets/folder_navigator.dart index 5fbf91dc..3c1adbe7 100644 --- a/lib/widgets/folder_navigator.dart +++ b/lib/widgets/folder_navigator.dart @@ -10,10 +10,9 @@ 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'; @@ -22,16 +21,11 @@ import 'package:icarus/providers/update_status_provider.dart'; import 'package:icarus/services/app_error_reporter.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/confirm_alert_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/folder_content.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'; @@ -48,18 +42,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(); @@ -92,6 +82,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; @@ -121,10 +113,6 @@ class _FolderNavigatorState extends ConsumerState { ); } - void _toggleImportExportPopover() { - _importExportPopoverController.toggle(); - } - Future handleImportIca() async { if (kIsWeb) { _showDesktopOnlyToast(); @@ -204,8 +192,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) { @@ -241,12 +243,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 @@ -258,6 +257,8 @@ class _FolderNavigatorState extends ConsumerState { .read(folderProvider.notifier) .findLocalFolderByID(currentFolderId) : null; + final canCreate = tab == LibraryTab.library; + Future navigateToLocalStrategy( BuildContext context, String strategyId, { @@ -295,7 +296,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(), @@ -306,185 +307,53 @@ 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, + ), + 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', + ShadContextMenuItem( + leading: const Icon(Icons.note_add_outlined), + onPressed: showCreateDialog, + child: const Text('Create Strategy'), + ), + ], + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 220), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeOutCubic, + child: KeyedSubtree( + key: ValueKey(tab), + child: FolderContent( + folder: currentFolder, + onCreateStrategy: showCreateDialog, ), ), - ], - ], - ) - ], - // ... 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: AnimatedSwitcher( - duration: const Duration(milliseconds: 220), - switchInCurve: Curves.easeOutCubic, - switchOutCurve: Curves.easeOutCubic, - child: KeyedSubtree( - key: ValueKey('$workspace/$cloudSection'), - child: FolderContent( - folder: currentFolder, - onCreateStrategy: showCreateDialog, ), ), ), - ), + ], ), ), - const Positioned( - left: 0, - top: 0, - bottom: 0, - child: LibraryNavigationRail(), - ), if (_desktopUpdaterController != null) DesktopUpdateDialogListener( controller: _desktopUpdaterController!, @@ -494,12 +363,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 { @@ -509,569 +384,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) { - // One accidental click on the avatar used - // to sign out instantly. - final confirmed = - await ConfirmAlertDialog.show( - context: context, - title: 'Sign out?', - content: - 'Cloud strategies stay online; your ' - 'local strategies stay on this ' - 'device.', - confirmText: 'Sign Out', - ); - if (!confirmed || !context.mounted) { - return; - } - unawaited( - ref.read(authProvider.notifier).signOut(), - ); - } 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 e72c2cbc..00000000 --- a/lib/widgets/folder_navigator_sidebar.dart +++ /dev/null @@ -1,971 +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/confirm_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) { - final item = details.data; - if (item is StrategyItem) { - ref.read(strategyProvider.notifier).moveToFolder( - strategyID: item.strategyId, - parentID: null, - source: item.strategy == null - ? StrategySource.cloud - : StrategySource.local, - ); - } else if (item is FolderItem) { - 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) { - final item = details.data; - if (item is StrategyItem) { - ref.read(strategyProvider.notifier).moveToFolder( - strategyID: item.strategyId, - parentID: folder.id, - source: item.strategy == null - ? StrategySource.cloud - : StrategySource.local, - ); - } else if (item is FolderItem) { - 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 { - final confirmed = await ConfirmAlertDialog.show( - context: context, - title: "Delete '${folder.name}'?", - content: - 'This also removes every strategy and subfolder inside it.', - confirmText: 'Delete', - isDestructive: true, - ); - if (!confirmed) { - return; - } - ref.read(folderProvider.notifier).deleteFolder( - folder.id, - 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 17d9c9e0..4f238689 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 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, + ); }, ); }, @@ -455,7 +466,7 @@ class _FolderPillState extends ConsumerState if (widget.isDemo) return; ref.read(folderProvider.notifier).deleteFolder( widget.folder.id, - workspace: ref.read(libraryWorkspaceProvider), + workspace: widget.store, ); } }); @@ -496,11 +507,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..54859dc4 --- /dev/null +++ b/lib/widgets/library_breadcrumb.dart @@ -0,0 +1,160 @@ +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) { + final item = details.data; + if (item is StrategyItem) { + ref.read(strategyProvider.notifier).moveToFolder( + strategyID: item.strategyId, + parentID: folder?.id, + source: item.strategy == null + ? StrategySource.cloud + : StrategySource.local, + ); + } else if (item is FolderItem) { + 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..1fa7f824 --- /dev/null +++ b/lib/widgets/library_title_strip.dart @@ -0,0 +1,501 @@ +import 'dart:async'; + +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/widgets/dialogs/confirm_alert_dialog.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; +const double _menuWidth = 200; + +/// 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 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 Spacer(), + 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: _menuWidth, + 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: _menuWidth, + 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: _menuWidth, + 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 { + // One accidental click on the avatar used to sign out instantly. + final confirmed = await ConfirmAlertDialog.show( + context: context, + title: 'Sign out?', + content: 'Cloud strategies stay online; your local strategies stay on ' + 'this device.', + confirmText: 'Sign Out', + ); + if (!confirmed || !mounted) return; + unawaited(ref.read(authProvider.notifier).signOut()); + } +} + +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: 8), + onPressed: () { + menu.hide(); + onPressed(); + }, + leading: SizedBox( + width: 18, + 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(8, 6, 8, 4), + 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_tile/strategy_tile.dart b/lib/widgets/strategy_tile/strategy_tile.dart index 4b7992ad..9aed91cf 100644 --- a/lib/widgets/strategy_tile/strategy_tile.dart +++ b/lib/widgets/strategy_tile/strategy_tile.dart @@ -34,6 +34,7 @@ class StrategyTile extends ConsumerStatefulWidget { const StrategyTile.local({ super.key, required this.strategyData, + this.showDeviceBadge = false, }) : cloudStrategy = null, canRename = true, canDuplicate = true, @@ -47,9 +48,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; @@ -302,6 +308,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..c1b757f2 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,6 +157,19 @@ class _SkeletonTopBar extends StatelessWidget { @override Widget build(BuildContext context) { final title = strategyName?.trim(); + return WindowDragArea( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const MacTrafficLightInset(), + Expanded(child: _buildBar(context, title)), + const WindowCaptionButtons(), + ], + ), + ); + } + + Widget _buildBar(BuildContext context, String? title) { return Padding( padding: const EdgeInsets.only(left: 15, top: 15, bottom: 10, right: 15), child: Row( diff --git a/lib/widgets/window_chrome.dart b/lib/widgets/window_chrome.dart new file mode 100644 index 00000000..db9cc1b8 --- /dev/null +++ b/lib/widgets/window_chrome.dart @@ -0,0 +1,187 @@ +import 'package:flutter/foundation.dart' + show TargetPlatform, defaultTargetPlatform, kIsWeb; +import 'package:flutter/material.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; + +/// Room reserved on the left for the native macOS traffic lights. +const double kMacTrafficLightInset = 78; + +bool get _isMacOS => !kIsWeb && defaultTargetPlatform == TargetPlatform.macOS; + +bool get _drawsCaptionButtons => + !kIsWeb && + (defaultTargetPlatform == TargetPlatform.windows || + 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); + } +} + +/// 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 strip that stands in for the native title bar: traffic-light inset, +/// the screen's own controls, caption buttons, all draggable. +class AppWindowStrip extends StatelessWidget { + const AppWindowStrip({super.key, required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + return WindowDragArea( + child: Container( + 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..d9dc319a 100644 --- a/macos/Runner/MainFlutterWindow.swift +++ b/macos/Runner/MainFlutterWindow.swift @@ -1,15 +1,83 @@ 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 { + /// Matches `kWindowStripHeight` in lib/widgets/window_chrome.dart. + private let titleStripHeight: CGFloat = 40 + private var layoutObservers: [NSObjectProtocol] = [] + 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) 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/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/widgets/cloud_beta_automation_semantics_test.dart b/test/widgets/cloud_beta_automation_semantics_test.dart index 74276ec4..65701f8c 100644 --- a/test/widgets/cloud_beta_automation_semantics_test.dart +++ b/test/widgets/cloud_beta_automation_semantics_test.dart @@ -7,7 +7,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:icarus/providers/auth_provider.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'; void main() { @@ -103,63 +103,74 @@ 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)); + }); + + testWidgets('New menu offers a strategy and a folder', (tester) async { + var created = 0; + await tester.pumpWidget( + _testApp(_strip(onCreateStrategy: () => created++)), + ); - Navigator.of(tester.element(find.byType(AuthDialog))).pop(); + 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); }); } +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( From 4cbfc025706c0335cb9bf1aa091265e7daef69be Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Thu, 3 Sep 2026 22:28:07 -0400 Subject: [PATCH 2/8] fix: align the editor's top row with the traffic lights The map card is 65px tall and the row had 15px of top padding, so the traffic lights (centred on the 40px strip) floated above every control. Drop the padding and top-align the row: the 40px controls now share the lights' centre line; only the map card hangs below. Co-Authored-By: Claude Fable 5.1 --- lib/strategy_view.dart | 7 +++++-- lib/widgets/strategy_view_skeleton.dart | 6 ++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/lib/strategy_view.dart b/lib/strategy_view.dart index cd0d631c..7ab894af 100644 --- a/lib/strategy_view.dart +++ b/lib/strategy_view.dart @@ -226,15 +226,18 @@ class _StrategyViewState extends ConsumerState const MacTrafficLightInset(), Expanded( child: Padding( - padding: - const EdgeInsets.only(left: 15, top: 15, bottom: 10, right: 15), + // No top padding: the 40px controls then share the traffic + // lights' centre line; only the taller map card hangs below it. + padding: const EdgeInsets.only(left: 15, bottom: 10, right: 15), child: LayoutBuilder( builder: (context, constraints) { final showDiscordLabel = constraints.maxWidth >= 1000; return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ ShadIconButton.ghost( foregroundColor: Colors.white, diff --git a/lib/widgets/strategy_view_skeleton.dart b/lib/widgets/strategy_view_skeleton.dart index c1b757f2..54e1ff3a 100644 --- a/lib/widgets/strategy_view_skeleton.dart +++ b/lib/widgets/strategy_view_skeleton.dart @@ -171,11 +171,13 @@ class _SkeletonTopBar extends StatelessWidget { Widget _buildBar(BuildContext context, String? title) { return Padding( - padding: const EdgeInsets.only(left: 15, top: 15, bottom: 10, right: 15), + padding: const EdgeInsets.only(left: 15, bottom: 10, right: 15), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ const _SkeletonBlock(width: 40, height: 40, radius: 8), const SizedBox(width: 5), @@ -184,7 +186,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), From 0bd7994c3ebecfc4a19285182c157efd60c99313 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Thu, 3 Sep 2026 23:09:29 -0400 Subject: [PATCH 3/8] fix: align the strategy switcher with window chrome --- lib/widgets/strategy_quick_switcher.dart | 4 +- .../strategy_quick_switcher_layout_test.dart | 76 +++++++++++++++++++ 2 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 test/widgets/strategy_quick_switcher_layout_test.dart 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/test/widgets/strategy_quick_switcher_layout_test.dart b/test/widgets/strategy_quick_switcher_layout_test.dart new file mode 100644 index 00000000..ef601144 --- /dev/null +++ b/test/widgets/strategy_quick_switcher_layout_test.dart @@ -0,0 +1,76 @@ +import 'dart:io'; + +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: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 quick switcher starts on the title-strip center line', + (tester) async { + 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: StrategyQuickSwitcher(), + ), + ), + ), + ), + ); + await tester.pump(); + + final control = tester.getRect( + find.byKey(const ValueKey('strategy-quick-switcher-control')), + ); + expect(control.top, 0); + 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, + ); + } +} From f5c600f0cb21ee29eca3016988e2864fdff4354b Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Thu, 3 Sep 2026 23:22:54 -0400 Subject: [PATCH 4/8] fix: center Windows chrome on the editor header --- lib/strategy_view.dart | 127 ++++++++---------- lib/widgets/strategy_view_skeleton.dart | 17 +-- lib/widgets/window_chrome.dart | 29 ++++ test/strategy_view_skeleton_test.dart | 49 ++++--- .../strategy_quick_switcher_layout_test.dart | 70 ++++++---- 5 files changed, 168 insertions(+), 124 deletions(-) diff --git a/lib/strategy_view.dart b/lib/strategy_view.dart index 7ab894af..190f7cab 100644 --- a/lib/strategy_view.dart +++ b/lib/strategy_view.dart @@ -217,82 +217,69 @@ class _StrategyViewState extends ConsumerState return Scaffold( body: Column( children: [ - // The native title bar is hidden (see window_chrome.dart), so the - // editor's top row doubles as the window's drag handle. - WindowDragArea( - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const MacTrafficLightInset(), - Expanded( - child: Padding( - // No top padding: the 40px controls then share the traffic - // lights' centre line; only the taller map card hangs below it. - padding: const EdgeInsets.only(left: 15, bottom: 10, right: 15), - child: LayoutBuilder( - builder: (context, constraints) { - final showDiscordLabel = constraints.maxWidth >= 1000; - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ShadIconButton.ghost( - foregroundColor: Colors.white, - onPressed: _leaveToLibrary, - icon: const Icon(Icons.home), - ), - const SizedBox(width: 5), - const 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: editorHeaderCrossAxisAlignment, + children: [ + Row( + crossAxisAlignment: editorHeaderCrossAxisAlignment, + 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 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 WindowCaptionButtons(), - ], + ], + ); + }, + ), ), ), const Expanded( diff --git a/lib/widgets/strategy_view_skeleton.dart b/lib/widgets/strategy_view_skeleton.dart index 54e1ff3a..ad5f3127 100644 --- a/lib/widgets/strategy_view_skeleton.dart +++ b/lib/widgets/strategy_view_skeleton.dart @@ -157,27 +157,18 @@ class _SkeletonTopBar extends StatelessWidget { @override Widget build(BuildContext context) { final title = strategyName?.trim(); - return WindowDragArea( - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const MacTrafficLightInset(), - Expanded(child: _buildBar(context, title)), - const WindowCaptionButtons(), - ], - ), - ); + return EditorWindowHeader(child: _buildBar(context, title)); } Widget _buildBar(BuildContext context, String? title) { return Padding( - padding: const EdgeInsets.only(left: 15, bottom: 10, right: 15), + padding: const EdgeInsets.symmetric(horizontal: 15), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: editorHeaderCrossAxisAlignment, children: [ Row( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: editorHeaderCrossAxisAlignment, children: [ const _SkeletonBlock(width: 40, height: 40, radius: 8), const SizedBox(width: 5), diff --git a/lib/widgets/window_chrome.dart b/lib/widgets/window_chrome.dart index db9cc1b8..79d8b59a 100644 --- a/lib/widgets/window_chrome.dart +++ b/lib/widgets/window_chrome.dart @@ -18,6 +18,9 @@ bool get _drawsCaptionButtons => (defaultTargetPlatform == TargetPlatform.windows || defaultTargetPlatform == TargetPlatform.linux); +CrossAxisAlignment get editorHeaderCrossAxisAlignment => + _drawsCaptionButtons ? CrossAxisAlignment.center : CrossAxisAlignment.start; + /// True on desktop builds, where the native title bar is hidden and the app /// owns that space. bool get hasCustomWindowChrome => _isMacOS || _drawsCaptionButtons; @@ -38,6 +41,32 @@ class WindowDragArea extends StatelessWidget { } } +/// On Windows and Linux, centers the editor controls and caption buttons on +/// the map card's 65px band. macOS keeps controls on its 40px traffic-light +/// strip. The canvas gap stays below either layout. +class EditorWindowHeader extends StatelessWidget { + const EditorWindowHeader({super.key, required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + return WindowDragArea( + child: Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Row( + crossAxisAlignment: editorHeaderCrossAxisAlignment, + children: [ + const MacTrafficLightInset(), + Expanded(child: child), + const WindowCaptionButtons(), + ], + ), + ), + ); + } +} + /// 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 { 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/strategy_quick_switcher_layout_test.dart b/test/widgets/strategy_quick_switcher_layout_test.dart index ef601144..88aca575 100644 --- a/test/widgets/strategy_quick_switcher_layout_test.dart +++ b/test/widgets/strategy_quick_switcher_layout_test.dart @@ -1,5 +1,6 @@ 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'; @@ -9,6 +10,7 @@ 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() { @@ -27,37 +29,59 @@ void main() { await hiveDirectory.delete(recursive: true); }); - testWidgets('editor quick switcher starts on the title-strip center line', - (tester) async { - await tester.binding.setSurfaceSize(const Size(800, 160)); - addTearDown(() => tester.binding.setSurfaceSize(null)); + 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: StrategyQuickSwitcher(), + 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(); + ); + await tester.pump(); + } finally { + debugDefaultTargetPlatformOverride = null; + } final control = tester.getRect( find.byKey(const ValueKey('strategy-quick-switcher-control')), ); - expect(control.top, 0); + final mapCard = tester.getRect( + find.byKey(const ValueKey('map-card-reference')), + ); + final captionButtons = tester.getRect(find.byType(WindowCaptionButtons)); + + expect(control.center.dy, mapCard.center.dy); + expect(captionButtons.center.dy, mapCard.center.dy); expect(control.height, 40); expect(tester.takeException(), isNull); }); From f497f4d2dbe470ec9b4ae97fd1fe00ad287e7f05 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Thu, 3 Sep 2026 23:36:17 -0400 Subject: [PATCH 5/8] fix: centre the editor header on one line on macOS too The traffic lights were pinned to the library strip's 40px band, so on macOS the 65px map card could never share their line. The editor header now tells the window its band height over a small method channel and MainFlutterWindow re-centres the lights on it; they move back up when the editor closes. With that, every platform centres the controls, the map card, and the window buttons on the same line. Co-Authored-By: Claude Fable 5.1 --- lib/strategy_view.dart | 4 +- lib/widgets/strategy_view_skeleton.dart | 4 +- lib/widgets/window_chrome.dart | 71 ++++++++++++++++++++----- macos/Runner/MainFlutterWindow.swift | 29 +++++++++- 4 files changed, 88 insertions(+), 20 deletions(-) diff --git a/lib/strategy_view.dart b/lib/strategy_view.dart index 190f7cab..bfc13f6b 100644 --- a/lib/strategy_view.dart +++ b/lib/strategy_view.dart @@ -225,10 +225,10 @@ class _StrategyViewState extends ConsumerState final showDiscordLabel = constraints.maxWidth >= 1000; return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: editorHeaderCrossAxisAlignment, + crossAxisAlignment: CrossAxisAlignment.center, children: [ Row( - crossAxisAlignment: editorHeaderCrossAxisAlignment, + crossAxisAlignment: CrossAxisAlignment.center, children: [ ShadIconButton.ghost( foregroundColor: Colors.white, diff --git a/lib/widgets/strategy_view_skeleton.dart b/lib/widgets/strategy_view_skeleton.dart index ad5f3127..70a79428 100644 --- a/lib/widgets/strategy_view_skeleton.dart +++ b/lib/widgets/strategy_view_skeleton.dart @@ -165,10 +165,10 @@ class _SkeletonTopBar extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 15), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: editorHeaderCrossAxisAlignment, + crossAxisAlignment: CrossAxisAlignment.center, children: [ Row( - crossAxisAlignment: editorHeaderCrossAxisAlignment, + crossAxisAlignment: CrossAxisAlignment.center, children: [ const _SkeletonBlock(width: 40, height: 40, radius: 8), const SizedBox(width: 5), diff --git a/lib/widgets/window_chrome.dart b/lib/widgets/window_chrome.dart index 79d8b59a..2ff6da36 100644 --- a/lib/widgets/window_chrome.dart +++ b/lib/widgets/window_chrome.dart @@ -1,6 +1,7 @@ import 'package:flutter/foundation.dart' show TargetPlatform, defaultTargetPlatform, kIsWeb; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:icarus/const/settings.dart'; import 'package:window_manager/window_manager.dart'; @@ -8,9 +9,29 @@ import 'package:window_manager/window_manager.dart'; /// `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 _drawsCaptionButtons => @@ -18,9 +39,6 @@ bool get _drawsCaptionButtons => (defaultTargetPlatform == TargetPlatform.windows || defaultTargetPlatform == TargetPlatform.linux); -CrossAxisAlignment get editorHeaderCrossAxisAlignment => - _drawsCaptionButtons ? CrossAxisAlignment.center : CrossAxisAlignment.start; - /// True on desktop builds, where the native title bar is hidden and the app /// owns that space. bool get hasCustomWindowChrome => _isMacOS || _drawsCaptionButtons; @@ -41,26 +59,51 @@ class WindowDragArea extends StatelessWidget { } } -/// On Windows and Linux, centers the editor controls and caption buttons on -/// the map card's 65px band. macOS keeps controls on its 40px traffic-light -/// strip. The canvas gap stays below either layout. -class EditorWindowHeader extends StatelessWidget { +/// 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. +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: const EdgeInsets.only(bottom: 10), - child: Row( - crossAxisAlignment: editorHeaderCrossAxisAlignment, - children: [ - const MacTrafficLightInset(), - Expanded(child: child), - const WindowCaptionButtons(), - ], + child: SizedBox( + height: kEditorHeaderHeight, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const MacTrafficLightInset(), + Expanded(child: widget.child), + const WindowCaptionButtons(), + ], + ), ), ), ); diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift index d9dc319a..245f4504 100644 --- a/macos/Runner/MainFlutterWindow.swift +++ b/macos/Runner/MainFlutterWindow.swift @@ -4,9 +4,13 @@ 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 { - /// Matches `kWindowStripHeight` in lib/widgets/window_chrome.dart. - private let titleStripHeight: CGFloat = 40 + /// 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() @@ -20,6 +24,27 @@ class MainFlutterWindow: NSWindow { 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() From c460097edba4cc97699e535e95fa9ab1b89360ac Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Fri, 4 Sep 2026 12:23:16 -0400 Subject: [PATCH 6/8] Add Windows library wordmark and fix title tabs --- assets/brand/icarus-wordmark.svg | 11 +++ lib/widgets/folder_navigator.dart | 18 ++--- lib/widgets/library_title_strip.dart | 16 ++-- lib/widgets/window_chrome.dart | 73 +++++++++++++------ pubspec.yaml | 1 + .../strategy_quick_switcher_layout_test.dart | 2 + 6 files changed, 81 insertions(+), 40 deletions(-) create mode 100644 assets/brand/icarus-wordmark.svg 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/lib/widgets/folder_navigator.dart b/lib/widgets/folder_navigator.dart index 3c1adbe7..7e3c0de2 100644 --- a/lib/widgets/folder_navigator.dart +++ b/lib/widgets/folder_navigator.dart @@ -337,17 +337,13 @@ class _FolderNavigatorState extends ConsumerState { child: const Text('Create Strategy'), ), ], - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 220), - switchInCurve: Curves.easeOutCubic, - switchOutCurve: Curves.easeOutCubic, - child: KeyedSubtree( - key: ValueKey(tab), - child: FolderContent( - folder: currentFolder, - onCreateStrategy: showCreateDialog, - ), - ), + // 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, ), ), ), diff --git a/lib/widgets/library_title_strip.dart b/lib/widgets/library_title_strip.dart index 1fa7f824..11156c6f 100644 --- a/lib/widgets/library_title_strip.dart +++ b/lib/widgets/library_title_strip.dart @@ -71,6 +71,7 @@ class _LibraryTitleStripState extends ConsumerState { return AppWindowStrip( child: Row( children: [ + const WindowsIcarusWordmark(), const SizedBox(width: 6), _TabButton( key: const ValueKey('library-tab-library'), @@ -105,7 +106,12 @@ class _LibraryTitleStripState extends ConsumerState { const SizedBox(width: 8), const DemoTag(), ], - const Spacer(), + const Expanded( + child: WindowDragArea( + key: ValueKey('library-window-drag-area'), + child: SizedBox.expand(), + ), + ), if (tab != LibraryTab.community) ...[ const SizedBox( height: _controlHeight, @@ -169,7 +175,7 @@ class _LibraryTitleStripState extends ConsumerState { ), const _MenuDivider(), _MenuItem( - menu: _sortController, + menu: _sortController, icon: isAscending ? LucideIcons.arrowUpNarrowWide : LucideIcons.arrowDownWideNarrow, @@ -219,14 +225,14 @@ class _LibraryTitleStripState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _MenuItem( - menu: _newController, + menu: _newController, key: const ValueKey('library-new-strategy'), icon: Icons.note_add_outlined, label: 'New Strategy', onPressed: widget.onCreateStrategy, ), _MenuItem( - menu: _newController, + menu: _newController, key: const ValueKey('library-new-folder'), icon: LucideIcons.folderPlus, label: 'New Folder', @@ -336,7 +342,7 @@ class _LibraryTitleStripState extends ConsumerState { ), const _MenuDivider(), _MenuItem( - menu: _accountController, + menu: _accountController, icon: LucideIcons.logOut, label: 'Sign Out', onPressed: _confirmSignOut, diff --git a/lib/widgets/window_chrome.dart b/lib/widgets/window_chrome.dart index 2ff6da36..dc9b3c7e 100644 --- a/lib/widgets/window_chrome.dart +++ b/lib/widgets/window_chrome.dart @@ -2,6 +2,7 @@ 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'; @@ -33,11 +34,11 @@ Future _syncMacTitleStripHeight() async { } bool get _isMacOS => !kIsWeb && defaultTargetPlatform == TargetPlatform.macOS; +bool get _isWindows => + !kIsWeb && defaultTargetPlatform == TargetPlatform.windows; bool get _drawsCaptionButtons => - !kIsWeb && - (defaultTargetPlatform == TargetPlatform.windows || - defaultTargetPlatform == TargetPlatform.linux); + _isWindows || (!kIsWeb && defaultTargetPlatform == TargetPlatform.linux); /// True on desktop builds, where the native title bar is hidden and the app /// owns that space. @@ -62,7 +63,8 @@ class WindowDragArea extends StatelessWidget { /// 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. +/// 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}); @@ -93,7 +95,9 @@ class _EditorWindowHeaderState extends State { Widget build(BuildContext context) { return WindowDragArea( child: Padding( - padding: const EdgeInsets.only(bottom: 10), + padding: _drawsCaptionButtons + ? const EdgeInsets.symmetric(vertical: 5) + : const EdgeInsets.only(bottom: 10), child: SizedBox( height: kEditorHeaderHeight, child: Row( @@ -110,6 +114,26 @@ class _EditorWindowHeaderState extends State { } } +/// 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 { @@ -151,7 +175,8 @@ class _MacTrafficLightInsetState extends State @override Widget build(BuildContext context) { - return SizedBox(width: _isMacOS && !_fullScreen ? kMacTrafficLightInset : 0); + return SizedBox( + width: _isMacOS && !_fullScreen ? kMacTrafficLightInset : 0); } } @@ -227,8 +252,9 @@ class _WindowCaptionButtonsState extends State } } -/// The strip that stands in for the native title bar: traffic-light inset, -/// the screen's own controls, caption buttons, all draggable. +/// 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}); @@ -236,24 +262,23 @@ class AppWindowStrip extends StatelessWidget { @override Widget build(BuildContext context) { - return WindowDragArea( - child: Container( - 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(), - ], + 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/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/widgets/strategy_quick_switcher_layout_test.dart b/test/widgets/strategy_quick_switcher_layout_test.dart index 88aca575..ab520206 100644 --- a/test/widgets/strategy_quick_switcher_layout_test.dart +++ b/test/widgets/strategy_quick_switcher_layout_test.dart @@ -79,9 +79,11 @@ void main() { 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); }); From a575fad96ea89a02403b908a8f0834f349e6bc0b Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Fri, 4 Sep 2026 13:21:55 -0400 Subject: [PATCH 7/8] Tighten library title controls --- lib/widgets/library_title_strip.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/widgets/library_title_strip.dart b/lib/widgets/library_title_strip.dart index 11156c6f..994b0bfd 100644 --- a/lib/widgets/library_title_strip.dart +++ b/lib/widgets/library_title_strip.dart @@ -123,7 +123,6 @@ class _LibraryTitleStripState extends ConsumerState { hintText: 'Search', ), ), - const SizedBox(width: 4), _buildSortMenu(), const SizedBox(width: 8), if (tab == LibraryTab.shared) From 6d3acaca9067de0e147803bb8faf5109a5babed4 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Fri, 4 Sep 2026 13:49:34 -0400 Subject: [PATCH 8/8] Tighten library dropdown layout --- lib/widgets/library_title_strip.dart | 43 +++++++++++++++++++--------- 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/lib/widgets/library_title_strip.dart b/lib/widgets/library_title_strip.dart index 994b0bfd..520365bb 100644 --- a/lib/widgets/library_title_strip.dart +++ b/lib/widgets/library_title_strip.dart @@ -18,7 +18,15 @@ import 'package:icarus/widgets/window_chrome.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; const double _controlHeight = 28; -const double _menuWidth = 200; +// 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. @@ -123,6 +131,7 @@ class _LibraryTitleStripState extends ConsumerState { hintText: 'Search', ), ), + const SizedBox(width: 4), _buildSortMenu(), const SizedBox(width: 8), if (tab == LibraryTab.shared) @@ -157,7 +166,7 @@ class _LibraryTitleStripState extends ConsumerState { overlayAlignment: Alignment.bottomRight, ), popover: (context) => SizedBox( - width: _menuWidth, + width: _sortMenuWidth, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, @@ -218,7 +227,7 @@ class _LibraryTitleStripState extends ConsumerState { overlayAlignment: Alignment.bottomRight, ), popover: (context) => SizedBox( - width: _menuWidth, + width: _newMenuWidth, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, @@ -312,7 +321,7 @@ class _LibraryTitleStripState extends ConsumerState { overlayAlignment: Alignment.bottomRight, ), popover: (context) => SizedBox( - width: _menuWidth, + width: _accountMenuWidth, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, @@ -451,13 +460,16 @@ class _MenuItem extends StatelessWidget { return ShadButton.ghost( height: 32, mainAxisAlignment: MainAxisAlignment.start, - padding: const EdgeInsets.symmetric(horizontal: 8), + padding: const EdgeInsets.symmetric( + horizontal: _menuItemHorizontalPadding, + ), + gap: _menuItemGap, onPressed: () { menu.hide(); onPressed(); }, leading: SizedBox( - width: 18, + width: _menuIconWidth, child: icon == null ? null : Icon(icon, size: 16), ), child: Flexible( @@ -479,14 +491,17 @@ class _MenuLabel extends StatelessWidget { @override Widget build(BuildContext context) { return Padding( - padding: const EdgeInsets.fromLTRB(8, 6, 8, 4), - child: Text( - text, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - letterSpacing: 0.3, - color: Settings.tacticalVioletTheme.mutedForeground, + 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, + ), ), ), );