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