diff --git a/DESIGN.md b/DESIGN.md
index 57fcfa51..3a4f89f1 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -13,6 +13,12 @@ The palette, theme, and sizing constants live in `lib/const/settings.dart`, with
- Type roles, all in the system sans stack: headline 20px/500, title 16px/600, body 14px/400, label 12px/600, micro 10px/600. Hierarchy comes from these five roles, not from display fonts or hero-scale type.
- 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, and New on the right (there is no account yet). Nothing else goes in it. Inside a folder, the breadcrumb lives in the content area, not the strip.
+- The editor's document actions (save, export, video, screenshot, settings) sit in one card at the top-left of the canvas (`lib/widgets/editor_toolbar.dart`). No status chips or labels in the editor.
+
## Things I would like to remain consistent
**The One Command Color** Violet marks current action, selection, focus, and primary commands, and nothing else. If violet appears somewhere that isn't actionable or active, it's wrong.
diff --git a/assets/brand/icarus-wordmark.svg b/assets/brand/icarus-wordmark.svg
new file mode 100644
index 00000000..580f6b69
--- /dev/null
+++ b/assets/brand/icarus-wordmark.svg
@@ -0,0 +1,11 @@
+
diff --git a/lib/main.dart b/lib/main.dart
index fb96e9a8..2ab5d91c 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -10,7 +10,6 @@ import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:hive_ce_flutter/adapters.dart';
-import 'package:windows_single_instance/windows_single_instance.dart';
import 'package:icarus/const/app_cursors.dart';
import 'package:icarus/const/custom_icons.dart';
import 'package:icarus/const/hive_boxes.dart';
@@ -28,10 +27,10 @@ import 'package:icarus/providers/map_provider.dart';
import 'package:icarus/providers/user_preferences_provider.dart';
import 'package:icarus/providers/strategy_provider.dart';
import 'package:icarus/services/app_error_reporter.dart';
+import 'package:icarus/services/desktop_runtime.dart';
import 'package:icarus/services/analytics_service.dart';
import 'package:icarus/services/discord_presence_service.dart';
import 'package:icarus/startup/hive_store_launch.dart';
-import 'package:icarus/startup/windows_process_termination.dart';
import 'package:icarus/strategy_view.dart';
import 'package:icarus/widgets/folder_navigator.dart';
import 'package:icarus/widgets/global_shortcuts.dart';
@@ -41,7 +40,6 @@ import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import 'package:toastification/toastification.dart';
-import 'package:window_manager/window_manager.dart';
CustomMouseCursor? staticDrawingCursor;
WebViewEnvironment? webViewEnvironment;
@@ -65,17 +63,11 @@ Future main(List args) async {
);
}
- if (!kIsWeb && Platform.isWindows) {
- await WindowsSingleInstance.ensureSingleInstance(
- launch.fileOpenArgs,
- alternateHiveStore?.windowsSingleInstanceId ??
- HiveStoreLaunch.defaultWindowsSingleInstanceId,
- onSecondWindow: (args) {
- publishSecondInstanceArgs(args);
- },
- exitFunction: terminateDuplicateWindowsProcess,
- );
- }
+ await ensureIcarusSingleInstance(
+ launch.fileOpenArgs,
+ instanceId: alternateHiveStore?.windowsSingleInstanceId ??
+ HiveStoreLaunch.defaultWindowsSingleInstanceId,
+ );
if (kIsWeb) {
// On web, Hive uses IndexedDB; no path needed.
@@ -123,18 +115,9 @@ Future main(List args) async {
await _initWebViewEnvironment();
if (!kIsWeb) {
- await windowManager.ensureInitialized();
- WindowOptions windowOptions = const WindowOptions(
- size: Size(1600, 900),
- minimumSize: Size(1280, 720),
- center: true,
- title:
- "Icarus: Valorant Strategies & Line ups ${Settings.versionName}",
+ await initializeIcarusDesktopWindow(
+ "Icarus: Valorant Strategies & Line ups ${Settings.versionName}",
);
- windowManager.waitUntilReadyToShow(windowOptions, () async {
- await windowManager.show();
- await windowManager.focus();
- });
}
// Ensure WebView2 environment is initialized on Windows before any InAppWebView
@@ -379,6 +362,11 @@ class _MyAppState extends ConsumerState {
brightness: Brightness.dark,
colorScheme: Settings.tacticalVioletTheme,
breadcrumbTheme: const ShadBreadcrumbTheme(separatorSize: 18),
+ // Ghost buttons are quiet controls (menu items, icon buttons),
+ // not primary commands, so they don't get the command color.
+ ghostButtonTheme: ShadButtonTheme(
+ foregroundColor: Settings.tacticalVioletTheme.foreground,
+ ),
),
home: const MyHomePage(),
routes: {
diff --git a/lib/providers/strategy_filter_provider.dart b/lib/providers/strategy_filter_provider.dart
index df04479c..a210b1d4 100644
--- a/lib/providers/strategy_filter_provider.dart
+++ b/lib/providers/strategy_filter_provider.dart
@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:icarus/providers/user_preferences_provider.dart';
+import 'package:shadcn_ui/shadcn_ui.dart';
enum SortBy {
alphabetical,
@@ -89,11 +90,11 @@ class StrategyFilterProvider extends Notifier {
switch (state.sortBy) {
case SortBy.alphabetical:
- return (icon: Icons.sort_by_alpha, label: label);
+ return (icon: LucideIcons.arrowDownAZ, label: label);
case SortBy.dateCreated:
- return (icon: Icons.calendar_today, label: label);
+ return (icon: LucideIcons.calendarPlus, label: label);
case SortBy.dateUpdated:
- return (icon: Icons.update, label: label);
+ return (icon: LucideIcons.history, label: label);
}
}
}
diff --git a/lib/services/desktop_runtime.dart b/lib/services/desktop_runtime.dart
new file mode 100644
index 00000000..122bef7c
--- /dev/null
+++ b/lib/services/desktop_runtime.dart
@@ -0,0 +1,2 @@
+export 'desktop_runtime_stub.dart'
+ if (dart.library.io) 'desktop_runtime_native.dart';
diff --git a/lib/services/desktop_runtime_native.dart b/lib/services/desktop_runtime_native.dart
new file mode 100644
index 00000000..92f80c55
--- /dev/null
+++ b/lib/services/desktop_runtime_native.dart
@@ -0,0 +1,44 @@
+import 'dart:io';
+
+import 'package:flutter/material.dart' show Size;
+import 'package:icarus/const/second_instance_args.dart';
+import 'package:icarus/startup/windows_process_termination.dart';
+import 'package:window_manager/window_manager.dart';
+import 'package:windows_single_instance/windows_single_instance.dart';
+
+bool get isWindowsRuntime => Platform.isWindows;
+
+Future ensureIcarusSingleInstance(
+ List args, {
+ required String instanceId,
+}) async {
+ if (!Platform.isWindows) {
+ return;
+ }
+
+ await WindowsSingleInstance.ensureSingleInstance(
+ args,
+ instanceId,
+ onSecondWindow: publishSecondInstanceArgs,
+ exitFunction: terminateDuplicateWindowsProcess,
+ );
+}
+
+Future initializeIcarusDesktopWindow(String title) async {
+ await windowManager.ensureInitialized();
+ final windowOptions = WindowOptions(
+ size: const Size(1600, 900),
+ 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();
+ await windowManager.focus();
+ });
+}
diff --git a/lib/services/desktop_runtime_stub.dart b/lib/services/desktop_runtime_stub.dart
new file mode 100644
index 00000000..ce339cac
--- /dev/null
+++ b/lib/services/desktop_runtime_stub.dart
@@ -0,0 +1,8 @@
+bool get isWindowsRuntime => false;
+
+Future ensureIcarusSingleInstance(
+ List args, {
+ required String instanceId,
+}) async {}
+
+Future initializeIcarusDesktopWindow(String title) async {}
diff --git a/lib/sidebar.dart b/lib/sidebar.dart
index f0b0e753..d20ff37a 100644
--- a/lib/sidebar.dart
+++ b/lib/sidebar.dart
@@ -43,7 +43,11 @@ class _SideBarUIState extends ConsumerState {
const AbiilityBar(),
Padding(
padding: const EdgeInsets.only(
- left: 0, right: Settings.sideBarPanelPaddingRight, bottom: 8),
+ left: 0,
+ top: 8,
+ right: Settings.sideBarPanelPaddingRight,
+ bottom: 8,
+ ),
child: ClipRRect(
borderRadius: _panelBorderRadius,
child: Container(
@@ -103,7 +107,7 @@ class _SideBarUIState extends ConsumerState {
.toggleFavoritesOnly();
},
icon: Icon(
- Icons.star_rounded,
+ LucideIcons.star600,
size: 24,
color: filterState.favoritesOnly
? Colors.white
diff --git a/lib/strategy_view.dart b/lib/strategy_view.dart
index 8fb0697f..30d4d244 100644
--- a/lib/strategy_view.dart
+++ b/lib/strategy_view.dart
@@ -21,10 +21,11 @@ import 'package:icarus/widgets/strategy_view_skeleton.dart';
import 'package:icarus/widgets/strategy_quick_switcher.dart';
import 'package:icarus/widgets/map_selector.dart';
import 'package:icarus/widgets/pages_bar.dart';
-import 'package:icarus/widgets/save_and_load_button.dart';
+import 'package:icarus/widgets/editor_toolbar.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 {
@@ -191,56 +192,46 @@ class _StrategyViewState extends ConsumerState
return Scaffold(
body: Column(
children: [
- Padding(
- padding: const EdgeInsets.only(
- left: 15,
- top: 15,
- bottom: 10,
- right: 15,
- ),
- child: Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ // The same 40px strip as the library, so the traffic lights never
+ // move: Library on the left, the strategy in the middle, Discord on
+ // the right. The map card lives on the canvas with the toolbar.
+ AppWindowStrip(
+ child: Stack(
children: [
Row(
children: [
- ShadIconButton.ghost(
- foregroundColor: Colors.white,
- onPressed: _leaveToLibrary,
- icon: const Icon(Icons.home),
+ const SizedBox(width: 6),
+ ShadTooltip(
+ builder: (context) => const Text('Library'),
+ child: ShadIconButton.ghost(
+ width: 28,
+ height: 28,
+ foregroundColor:
+ Settings.tacticalVioletTheme.mutedForeground,
+ hoverForegroundColor:
+ Settings.tacticalVioletTheme.foreground,
+ onPressed: _leaveToLibrary,
+ icon: const Icon(LucideIcons.house300, size: 18),
+ ),
),
- const SizedBox(width: 5),
- const MapSelector(),
if (kIsWeb)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 8.0),
child: DemoTag(),
),
- ],
- ),
- const StrategyQuickSwitcher(),
- Row(
- children: [
- 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 Expanded(
+ child: WindowDragArea(child: SizedBox.expand()),
),
+ const _DiscordLink(),
+ const SizedBox(width: 10),
],
),
+ const Center(child: StrategyQuickSwitcher()),
],
),
),
+ // The canvas runs right up to the strip; each floating panel keeps
+ // its own 8px of air so no bare band shows between the two.
const Expanded(
child: Stack(
clipBehavior: Clip.none,
@@ -250,7 +241,21 @@ class _StrategyViewState extends ConsumerState
alignment: Alignment.centerLeft,
child: RepaintBoundary(child: InteractiveMap()),
),
- Align(alignment: Alignment.topLeft, child: SaveAndLoadButton()),
+ Align(
+ alignment: Alignment.topLeft,
+ child: Padding(
+ padding: EdgeInsets.all(8),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ MapSelector(),
+ SizedBox(height: 8),
+ EditorToolbar(),
+ ],
+ ),
+ ),
+ ),
Align(
alignment: Alignment.bottomLeft,
child: Padding(
@@ -289,3 +294,43 @@ class _StrategyViewState extends ConsumerState
);
}
}
+
+/// The bug-report link at the end of the editor strip: text when there is
+/// room, the glyph alone when there isn't.
+class _DiscordLink extends StatelessWidget {
+ const _DiscordLink();
+
+ @override
+ Widget build(BuildContext context) {
+ const theme = Settings.tacticalVioletTheme;
+ final showLabel = MediaQuery.sizeOf(context).width >= 1000;
+ final button = ShadButton.ghost(
+ height: 28,
+ padding: const EdgeInsets.symmetric(horizontal: 8),
+ foregroundColor: theme.mutedForeground,
+ hoverForegroundColor: theme.foreground,
+ onPressed: () async {
+ await launchUrl(Settings.dicordLink);
+ },
+ leading: showLabel ? null : const Icon(CustomIcons.discord, size: 16),
+ child: showLabel
+ ? const Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Text(
+ 'Have any bugs? Join the Discord',
+ style: TextStyle(fontSize: 13),
+ ),
+ SizedBox(width: 8),
+ Icon(CustomIcons.discord, size: 16),
+ ],
+ )
+ : const SizedBox.shrink(),
+ );
+ if (showLabel) return button;
+ return ShadTooltip(
+ builder: (context) => const Text('Have any bugs? Join the Discord'),
+ child: button,
+ );
+ }
+}
diff --git a/lib/widgets/better_color_picker.dart b/lib/widgets/better_color_picker.dart
index 538eca35..611e6e85 100644
--- a/lib/widgets/better_color_picker.dart
+++ b/lib/widgets/better_color_picker.dart
@@ -1004,7 +1004,7 @@ class _ModeField extends material.StatelessWidget {
);
},
trailing: material.Icon(
- material.Icons.unfold_more,
+ LucideIcons.chevronsUpDown,
size: 16,
color: palette.mutedForeground,
),
diff --git a/lib/widgets/color_picker_button.dart b/lib/widgets/color_picker_button.dart
index 9743d2d4..c1111167 100644
--- a/lib/widgets/color_picker_button.dart
+++ b/lib/widgets/color_picker_button.dart
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:shadcn_ui/shadcn_ui.dart';
class ColorPickerButton extends ConsumerStatefulWidget {
const ColorPickerButton({
@@ -61,7 +62,7 @@ class _ColorButtonsState extends ConsumerState {
strokeAlign: BorderSide.strokeAlignCenter,
),
),
- child: const Icon(Icons.add),
+ child: const Icon(LucideIcons.plus, size: 18),
),
),
),
diff --git a/lib/widgets/current_path_bar.dart b/lib/widgets/current_path_bar.dart
deleted file mode 100644
index 1ba667f9..00000000
--- a/lib/widgets/current_path_bar.dart
+++ /dev/null
@@ -1,98 +0,0 @@
-import 'package:flutter/material.dart';
-import 'package:flutter_riverpod/flutter_riverpod.dart';
-import 'package:icarus/const/settings.dart';
-import 'package:icarus/providers/folder_provider.dart';
-import 'package:icarus/providers/strategy_provider.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 currentFolderId = ref.watch(folderProvider);
- final currentFolder = currentFolderId != null
- ? ref.read(folderProvider.notifier).findFolderByID(currentFolderId)
- : null;
-
- final pathIds =
- ref.read(folderProvider.notifier).getFullPathIDs(currentFolder);
-
- return Container(
- padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 8),
- // ignore: prefer_const_constructors
- child: Row(
- children: [
- Expanded(
- child: ShadBreadcrumb(
- lastItemTextColor: Settings.tacticalVioletTheme.foreground,
- textStyle: ShadTheme.of(context).textTheme.lead,
- children: [
- FolderTab(
- folder: null, // Represents root
- isActive: currentFolder == null,
- ),
-
- // Path folders
- for (int i = 0; i < pathIds.length; i++) ...[
- FolderTab(
- folder: ref
- .read(folderProvider.notifier)
- .findFolderByID(pathIds[i]),
- isActive: i == pathIds.length - 1,
- ),
- ],
- ],
- ),
- ),
- ],
- ));
- }
-}
-
-class FolderTab extends ConsumerWidget {
- const FolderTab({
- super.key,
- required this.folder,
- this.isActive = false,
- });
-
- final Folder? folder; // null for root
- final bool isActive;
-
- @override
- Widget build(BuildContext context, WidgetRef ref) {
- final displayName = folder?.name ?? "Home";
-
- return ShadBreadcrumbLink(
- textStyle: ShadTheme.of(context).textTheme.lead,
- normalColor: isActive ? Settings.tacticalVioletTheme.foreground : null,
- child: DragTarget(
- builder: (context, candidateData, rejectedData) {
- return Container(
- padding: const EdgeInsets.symmetric(vertical: 4),
- child: Text(displayName),
- );
- },
- onAcceptWithDetails: (details) {
- final item = details.data;
- if (item is StrategyItem) {
- // Move strategy to this folder
- ref.read(strategyProvider.notifier).moveToFolder(
- strategyID: item.strategy.id, parentID: folder?.id);
- } else if (item is FolderItem) {
- // Move folder to this folder
-
- ref
- .read(folderProvider.notifier)
- .moveToFolder(folderID: item.folder.id, parentID: folder?.id);
- }
- },
- ),
- onPressed: () {
- ref.read(folderProvider.notifier).updateID(folder?.id);
- },
- );
- }
-}
diff --git a/lib/widgets/custom_expansion_tile.dart b/lib/widgets/custom_expansion_tile.dart
index a9c5227f..b52e59d7 100644
--- a/lib/widgets/custom_expansion_tile.dart
+++ b/lib/widgets/custom_expansion_tile.dart
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
+import 'package:shadcn_ui/shadcn_ui.dart';
class CustomExpansionTile extends StatefulWidget {
/// Creates a custom expansion tile that keeps a persistent row visible
@@ -175,7 +176,8 @@ class _CustomExpansionTileState extends State
RotationTransition(
turns: _iconTurns,
child: Icon(
- Icons.expand_more,
+ LucideIcons.chevronDown,
+ size: 20,
color: iconColor,
),
),
diff --git a/lib/widgets/custom_search_field.dart b/lib/widgets/custom_search_field.dart
index 12939e3c..37eeff9b 100644
--- a/lib/widgets/custom_search_field.dart
+++ b/lib/widgets/custom_search_field.dart
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:icarus/const/settings.dart';
import 'package:icarus/widgets/text_editing_shortcut_scope.dart';
+import 'package:shadcn_ui/shadcn_ui.dart';
/// A themed search text field that smoothly expands (slides out) when:
/// - Hovered by the pointer
@@ -171,7 +172,7 @@ class _SearchTextFieldState extends ConsumerState {
? const EdgeInsets.only(left: 8, right: 8)
: const EdgeInsets.only(left: 12, right: 8),
child: Icon(
- Icons.search,
+ LucideIcons.search,
color: Colors.white,
size: compact ? 18 : 20,
),
@@ -184,7 +185,7 @@ class _SearchTextFieldState extends ConsumerState {
? IconButton(
tooltip: 'Clear',
icon: Icon(
- Icons.close,
+ LucideIcons.x,
size: compact ? 18 : 20,
color: Colors.white70,
),
diff --git a/lib/widgets/custom_text_field.dart b/lib/widgets/custom_text_field.dart
index d55723ff..85684865 100644
--- a/lib/widgets/custom_text_field.dart
+++ b/lib/widgets/custom_text_field.dart
@@ -12,6 +12,7 @@ class CustomTextField extends ConsumerWidget {
this.minLines,
this.maxLines,
this.onSubmitted,
+ this.autofocus = false,
// required this.onEnterPressed,
});
final TextEditingController? controller;
@@ -20,6 +21,7 @@ class CustomTextField extends ConsumerWidget {
final int? minLines;
final int? maxLines;
final Function(String)? onSubmitted;
+ final bool autofocus;
// final Function(EnterTextIntent intent) onEnterPressed;
@override
@@ -27,6 +29,7 @@ class CustomTextField extends ConsumerWidget {
return TextEditingShortcutScope(
child: ShadInput(
controller: controller,
+ autofocus: autofocus,
textAlign: textAlign ?? TextAlign.start,
minLines: minLines,
maxLines: maxLines ?? 1,
diff --git a/lib/widgets/delete_area.dart b/lib/widgets/delete_area.dart
index fb0916c9..f1e3cc3a 100644
--- a/lib/widgets/delete_area.dart
+++ b/lib/widgets/delete_area.dart
@@ -10,6 +10,7 @@ import 'package:icarus/providers/delete_menu_provider.dart';
import 'package:icarus/providers/screenshot_provider.dart';
import 'package:icarus/widgets/delete_helpers.dart';
import 'package:icarus/widgets/sidebar_widgets/delete_options.dart';
+import 'package:shadcn_ui/shadcn_ui.dart';
class DeleteArea extends ConsumerStatefulWidget {
const DeleteArea({super.key});
@@ -225,7 +226,9 @@ class _DeleteAreaState extends ConsumerState
: const Duration(milliseconds: 140);
return Padding(
- padding: const EdgeInsets.all(16),
+ // Same 8px off the strip as the map card on the left, so both hang
+ // from the same line.
+ padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: CompositedTransformTarget(
link: _layerLink,
child: OverlayPortal(
@@ -329,8 +332,8 @@ class _DeleteAreaState extends ConsumerState
children: [
Center(
child: Icon(
- Icons.delete_outline,
- size: 24,
+ LucideIcons.trash2,
+ size: 22,
color: iconColor,
),
),
diff --git a/lib/widgets/demo_dialog.dart b/lib/widgets/demo_dialog.dart
index 03afda3e..caf87a48 100644
--- a/lib/widgets/demo_dialog.dart
+++ b/lib/widgets/demo_dialog.dart
@@ -21,7 +21,7 @@ class DemoDialog extends ConsumerWidget {
actions: [
ShadButton.secondary(
leading: const Icon(
- Icons.close,
+ LucideIcons.x,
),
onPressed: () {
Navigator.of(context).pop();
@@ -29,7 +29,7 @@ class DemoDialog extends ConsumerWidget {
child: const Text('Close'),
),
ShadButton(
- leading: const Icon(Icons.download),
+ leading: const Icon(LucideIcons.download),
onPressed: () async {
await launchUrl(Settings.windowsStoreLink);
},
diff --git a/lib/widgets/demo_tag.dart b/lib/widgets/demo_tag.dart
index 2aed2d6f..3100be63 100644
--- a/lib/widgets/demo_tag.dart
+++ b/lib/widgets/demo_tag.dart
@@ -42,7 +42,7 @@ class DemoTag extends ConsumerWidget {
mainAxisSize: MainAxisSize.min,
children: [
Icon(
- Icons.science_outlined,
+ LucideIcons.flaskConical,
size: 14,
color: Colors.white.withAlpha(230), // 0.9
),
diff --git a/lib/widgets/dialogs/lineup_panel_dialog.dart b/lib/widgets/dialogs/lineup_panel_dialog.dart
index 9358a58e..f4aeacd4 100644
--- a/lib/widgets/dialogs/lineup_panel_dialog.dart
+++ b/lib/widgets/dialogs/lineup_panel_dialog.dart
@@ -354,7 +354,7 @@ class _LineUpRowState extends State<_LineUpRow> {
child: const Text('Rename'),
),
ShadContextMenuItem(
- leading: Icon(Icons.delete, color: theme.destructive),
+ leading: Icon(LucideIcons.trash2, color: theme.destructive),
onPressed: widget.onDelete,
child: const Text('Delete lineup'),
),
diff --git a/lib/widgets/dialogs/strategy/create_strategy_dialog.dart b/lib/widgets/dialogs/strategy/create_strategy_dialog.dart
index ec8f521f..d320baef 100644
--- a/lib/widgets/dialogs/strategy/create_strategy_dialog.dart
+++ b/lib/widgets/dialogs/strategy/create_strategy_dialog.dart
@@ -15,6 +15,7 @@ class CreateStrategyDialog extends ConsumerStatefulWidget {
class _NameStrategyDialogState extends ConsumerState {
final TextEditingController _textController = TextEditingController();
+ bool _isSubmitting = false;
@override
void dispose() {
@@ -22,60 +23,52 @@ class _NameStrategyDialogState extends ConsumerState {
super.dispose();
}
+ Future _submit() async {
+ if (_isSubmitting) return;
+ final strategyName = _textController.text.trim();
+ if (strategyName.isEmpty) {
+ Settings.showToast(
+ message: 'Strategy name cannot be empty.',
+ backgroundColor: Settings.tacticalVioletTheme.destructive,
+ );
+ return;
+ }
+
+ setState(() => _isSubmitting = true);
+ try {
+ final strategyID = await ref
+ .read(strategyProvider.notifier)
+ .createNewStrategy(strategyName);
+ if (!mounted) return;
+ Navigator.of(context).pop(strategyID);
+ } catch (_) {
+ if (mounted) setState(() => _isSubmitting = false);
+ Settings.showToast(
+ message: "Couldn't create strategy right now.",
+ backgroundColor: Settings.tacticalVioletTheme.destructive,
+ );
+ }
+ }
+
@override
Widget build(BuildContext context) {
return ShadDialog(
- title: const Text("Create Strategy"),
+ title: const Text('Create Strategy'),
actions: [
ShadButton(
- child: const Text("Create"),
- onPressed: () async {
- final strategyName = _textController.text;
- if (strategyName.isNotEmpty) {
- final strategyID = await ref
- .read(strategyProvider.notifier)
- .createNewStrategy(strategyName);
- if (!context.mounted) return;
- Navigator.of(context).pop(strategyID); // Close the dialog
- } else {
- // Optionally, show an error message if the name is empty
- Settings.showToast(
- message: "Strategy name cannot be empty.",
- backgroundColor: Settings.tacticalVioletTheme.destructive,
- );
- }
- },
- )
+ onPressed: _isSubmitting ? null : _submit,
+ child: Text(_isSubmitting ? 'Creating…' : 'Create'),
+ ),
],
child: SizedBox(
width: 300,
child: CustomTextField(
- // onEnterPressed: (intent) {},
- hintText: "Enter strategy name",
+ hintText: 'Enter strategy name',
controller: _textController,
-
- onSubmitted: (value) async {
- if (value.isNotEmpty) {
- final strategyID = await ref
- .read(strategyProvider.notifier)
- .createNewStrategy(value);
- if (!context.mounted) return;
- Navigator.of(context).pop(strategyID); // Close the dialog
- } else {
- // Optionally, show an error message if the name is empty
- Settings.showToast(
- message: "Strategy name cannot be empty.",
- backgroundColor: Settings.tacticalVioletTheme.destructive,
- );
- }
- },
+ autofocus: true,
+ onSubmitted: (_) => _submit(),
),
),
);
}
}
-// How to use it:
-// showDialog(
-// context: context,
-// builder: (context) => const NameStrategyDialog(),
-// );
diff --git a/lib/widgets/dialogs/strategy/delete_strategy_alert_dialog.dart b/lib/widgets/dialogs/strategy/delete_strategy_alert_dialog.dart
index 1d9e2597..cb502aae 100644
--- a/lib/widgets/dialogs/strategy/delete_strategy_alert_dialog.dart
+++ b/lib/widgets/dialogs/strategy/delete_strategy_alert_dialog.dart
@@ -62,7 +62,7 @@ class DeleteStrategyAlertDialog extends ConsumerWidget {
child: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
- Icon(Icons.delete_forever, color: Colors.white),
+ Icon(LucideIcons.trash2, color: Colors.white),
SizedBox(width: 5),
Text(
"Delete",
diff --git a/lib/widgets/dialogs/strategy/line_up_media_page.dart b/lib/widgets/dialogs/strategy/line_up_media_page.dart
index 49dcda1e..f64b6251 100644
--- a/lib/widgets/dialogs/strategy/line_up_media_page.dart
+++ b/lib/widgets/dialogs/strategy/line_up_media_page.dart
@@ -8,6 +8,7 @@ import 'package:icarus/providers/image_provider.dart';
import 'package:icarus/providers/strategy_provider.dart';
import 'package:icarus/widgets/custom_text_field.dart';
import 'package:path/path.dart' as path;
+import 'package:shadcn_ui/shadcn_ui.dart';
class LineupMediaPage extends ConsumerStatefulWidget {
final TextEditingController youtubeLinkController;
@@ -65,7 +66,8 @@ class _LineupMediaPageState extends ConsumerState {
const SizedBox(height: 8),
CustomTextField(
controller: widget.nameController!,
- hintText: "Optional, for telling this apart from other lineups here",
+ hintText:
+ "Optional, for telling this apart from other lineups here",
),
const SizedBox(height: 24),
],
@@ -117,7 +119,7 @@ class _LineupMediaPageState extends ConsumerState {
child: Column(
children: [
Icon(
- Icons.add_photo_alternate_outlined,
+ LucideIcons.imagePlus,
size: 48,
color: Settings.tacticalVioletTheme.cardForeground,
),
@@ -182,7 +184,7 @@ class _LineupMediaPageState extends ConsumerState {
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Settings.tacticalVioletTheme.border),
),
- child: const Icon(Icons.add, color: Colors.white),
+ child: const Icon(LucideIcons.plus, color: Colors.white),
),
),
);
@@ -202,7 +204,7 @@ class _LineupMediaPageState extends ConsumerState {
child: const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
- Icon(Icons.content_paste, color: Colors.white),
+ Icon(LucideIcons.clipboardPaste, color: Colors.white),
SizedBox(height: 4),
Text(
"Paste",
@@ -230,7 +232,7 @@ class _LineupMediaPageState extends ConsumerState {
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
- Icon(Icons.content_paste, color: Colors.white, size: 16),
+ Icon(LucideIcons.clipboardPaste, color: Colors.white, size: 16),
SizedBox(width: 6),
Text(
"Paste from clipboard",
@@ -272,7 +274,7 @@ class _LineupMediaPageState extends ConsumerState {
color: Colors.black54,
shape: BoxShape.circle,
),
- child: const Icon(Icons.close, size: 14, color: Colors.white),
+ child: const Icon(LucideIcons.x, size: 14, color: Colors.white),
),
),
),
diff --git a/lib/widgets/dialogs/strategy/rename_strategy_dialog.dart b/lib/widgets/dialogs/strategy/rename_strategy_dialog.dart
index 81c59c56..a4aaa73c 100644
--- a/lib/widgets/dialogs/strategy/rename_strategy_dialog.dart
+++ b/lib/widgets/dialogs/strategy/rename_strategy_dialog.dart
@@ -64,7 +64,7 @@ class _RenameStrategyDialogState extends ConsumerState {
}
},
height: 35,
- leading: const Icon(Icons.text_fields),
+ leading: const Icon(LucideIcons.pencil),
child: const Text("Rename"),
),
],
diff --git a/lib/widgets/dialogs/upload_image_dialog.dart b/lib/widgets/dialogs/upload_image_dialog.dart
index 0a68bed5..bf4859c1 100644
--- a/lib/widgets/dialogs/upload_image_dialog.dart
+++ b/lib/widgets/dialogs/upload_image_dialog.dart
@@ -333,9 +333,7 @@ class _EmptyState extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: [
Icon(
- isDragging
- ? Icons.file_download_outlined
- : Icons.add_photo_alternate_outlined,
+ isDragging ? LucideIcons.download : LucideIcons.imagePlus,
size: 44,
color: isDragging ? cs.primary : cs.onSurfaceVariant,
),
@@ -404,7 +402,7 @@ class _SelectionFooter extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
child: Row(
children: [
- const Icon(Icons.image_outlined, size: 18),
+ const Icon(LucideIcons.image, size: 18),
const SizedBox(width: 8),
Expanded(
child: Text(
diff --git a/lib/widgets/draggable_widgets/ability/ability_visibility_context_menu.dart b/lib/widgets/draggable_widgets/ability/ability_visibility_context_menu.dart
index 4502d201..94482ec6 100644
--- a/lib/widgets/draggable_widgets/ability/ability_visibility_context_menu.dart
+++ b/lib/widgets/draggable_widgets/ability/ability_visibility_context_menu.dart
@@ -112,7 +112,8 @@ List buildLandingLineUpMenuItems(
),
ShadContextMenuItem(
leading: Icon(
- Icons.delete,
+ LucideIcons.trash2,
+ size: 16,
color: Settings.tacticalVioletTheme.destructive,
),
child: Text(links.length > 1 ? 'Delete spot' : 'Delete lineup'),
@@ -278,7 +279,8 @@ ShadContextMenuItem _buildToggleItem({
return ShadContextMenuItem(
onPressed: onPressed,
leading: Icon(
- isEnabled ? Icons.check_box : Icons.check_box_outline_blank,
+ isEnabled ? LucideIcons.squareCheck : LucideIcons.square,
+ size: 16,
),
child: Text(label),
);
diff --git a/lib/widgets/draggable_widgets/adjacent_page_copy_menu.dart b/lib/widgets/draggable_widgets/adjacent_page_copy_menu.dart
index adaa4ca2..a4f29e28 100644
--- a/lib/widgets/draggable_widgets/adjacent_page_copy_menu.dart
+++ b/lib/widgets/draggable_widgets/adjacent_page_copy_menu.dart
@@ -16,7 +16,7 @@ List buildAdjacentPageCopyMenuItems(
return [
if (directions.contains(PageTransitionDirection.backward))
ShadContextMenuItem(
- leading: const Icon(Icons.arrow_upward),
+ leading: const Icon(LucideIcons.arrowUp, size: 16),
child: const Text('Copy to previous page'),
onPressed: () async {
await notifier.copyPlacedWidgetToAdjacentPage(
@@ -27,7 +27,7 @@ List buildAdjacentPageCopyMenuItems(
),
if (directions.contains(PageTransitionDirection.forward))
ShadContextMenuItem(
- leading: const Icon(Icons.arrow_downward),
+ leading: const Icon(LucideIcons.arrowDown, size: 16),
child: const Text('Copy to next page'),
onPressed: () async {
await notifier.copyPlacedWidgetToAdjacentPage(
diff --git a/lib/widgets/draggable_widgets/agents/agent_widget.dart b/lib/widgets/draggable_widgets/agents/agent_widget.dart
index 6be6ff9e..633e9217 100644
--- a/lib/widgets/draggable_widgets/agents/agent_widget.dart
+++ b/lib/widgets/draggable_widgets/agents/agent_widget.dart
@@ -259,7 +259,8 @@ class AgentWidget extends ConsumerWidget {
if (canInteract && lineUpId != null)
ShadContextMenuItem(
leading: Icon(
- Icons.delete,
+ LucideIcons.trash2,
+ size: 16,
color: Settings.tacticalVioletTheme.destructive,
),
child: const Text('Delete origin'),
diff --git a/lib/widgets/draggable_widgets/utilities/placed_custom_rectangle_widget.dart b/lib/widgets/draggable_widgets/utilities/placed_custom_rectangle_widget.dart
index ef887d62..67e7f2e1 100644
--- a/lib/widgets/draggable_widgets/utilities/placed_custom_rectangle_widget.dart
+++ b/lib/widgets/draggable_widgets/utilities/placed_custom_rectangle_widget.dart
@@ -17,6 +17,7 @@ import 'package:icarus/widgets/draggable_widgets/utilities/custom_shape_resize_t
import 'package:icarus/widgets/draggable_widgets/utilities/rectangle_axis_resize_geometry.dart';
import 'package:icarus/widgets/draggable_widgets/utilities/shape_indicator_fade.dart';
import 'package:icarus/widgets/draggable_widgets/zoom_transform.dart';
+import 'package:shadcn_ui/shadcn_ui.dart';
enum _RectangleResizeHandle { none, left, right, top, bottom }
@@ -841,7 +842,7 @@ class _RotationBadge extends StatelessWidget {
curve: Curves.easeOutCubic,
scale: isEmphasized ? 1.0 : 0.9,
child: Icon(
- Icons.rotate_right_rounded,
+ LucideIcons.rotateCw,
size: size,
color: isActive ? Settings.tacticalVioletTheme.primary : Colors.white,
shadows: const [
diff --git a/lib/widgets/draggable_widgets/utilities/view_cone_elevation_menu.dart b/lib/widgets/draggable_widgets/utilities/view_cone_elevation_menu.dart
index 096e8503..f36d0f0d 100644
--- a/lib/widgets/draggable_widgets/utilities/view_cone_elevation_menu.dart
+++ b/lib/widgets/draggable_widgets/utilities/view_cone_elevation_menu.dart
@@ -9,7 +9,7 @@ ShadContextMenuItem buildViewConeElevationMenuItem({
required ValueChanged onChanged,
}) {
return ShadContextMenuItem(
- leading: const Icon(Icons.layers_outlined),
+ leading: const Icon(LucideIcons.layers, size: 16),
trailing: Text(
selectedElevation == null
? 'Auto ${formatVisionElevation(automaticElevation)}'
@@ -45,7 +45,7 @@ ShadContextMenuItem buildViewConeDebugMenuItem({
required ValueChanged onChanged,
}) {
return ShadContextMenuItem(
- leading: Icon(enabled ? Icons.visibility : Icons.visibility_outlined),
+ leading: Icon(enabled ? LucideIcons.eye : LucideIcons.eyeOff, size: 16),
trailing: Text(enabled ? 'On' : 'Off'),
onPressed: () => onChanged(!enabled),
child: const Text('Vision calibration'),
@@ -59,7 +59,8 @@ ShadContextMenuItem _elevationItem({
}) {
return ShadContextMenuItem(
leading: Icon(
- selected ? Icons.radio_button_checked : Icons.radio_button_off,
+ selected ? LucideIcons.circleDot : LucideIcons.circle,
+ size: 16,
),
onPressed: onPressed,
child: Text(label),
diff --git a/lib/widgets/editor_toolbar.dart b/lib/widgets/editor_toolbar.dart
new file mode 100644
index 00000000..f60959c8
--- /dev/null
+++ b/lib/widgets/editor_toolbar.dart
@@ -0,0 +1,309 @@
+import 'dart:io';
+
+import 'package:file_picker/file_picker.dart';
+import 'package:flutter/foundation.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:hive_ce/hive.dart';
+import 'package:icarus/const/coordinate_system.dart';
+import 'package:icarus/const/hive_boxes.dart';
+import 'package:icarus/const/settings.dart';
+import 'package:icarus/providers/drawing_provider.dart';
+import 'package:icarus/providers/map_provider.dart';
+import 'package:icarus/providers/screenshot_provider.dart';
+import 'package:icarus/providers/strategy_provider.dart';
+import 'package:icarus/screenshot/offscreen_capture.dart';
+import 'package:icarus/screenshot/screenshot_view.dart';
+import 'package:icarus/widgets/dialogs/export_video_dialog.dart';
+import 'package:icarus/widgets/settings_tab.dart';
+import 'package:icarus/widgets/strategy_save_icon_button.dart';
+import 'package:screenshot/screenshot.dart';
+import 'package:shadcn_ui/shadcn_ui.dart';
+
+/// Geometry shared by every control in the editor's floating toolbar, so the
+/// save button matches its neighbours exactly.
+class EditorToolbarButtonStyle {
+ const EditorToolbarButtonStyle({
+ this.size = 32,
+ this.iconSize = 18,
+ });
+
+ final double size;
+ final double iconSize;
+}
+
+const EditorToolbarButtonStyle kEditorToolbarButtonStyle =
+ EditorToolbarButtonStyle();
+
+/// The document actions of the open strategy, docked at the top-left of the
+/// canvas as one card: save, export, video, screenshot, then settings.
+class EditorToolbar extends ConsumerStatefulWidget {
+ const EditorToolbar({super.key});
+
+ @override
+ ConsumerState createState() => _EditorToolbarState();
+}
+
+class _EditorToolbarState extends ConsumerState {
+ bool _isCapturingScreenshot = false;
+
+ @override
+ Widget build(BuildContext context) {
+ const style = kEditorToolbarButtonStyle;
+
+ return Padding(
+ padding: const EdgeInsets.all(8),
+ child: Row(
+ children: [
+ Container(
+ padding: const EdgeInsets.all(4),
+ decoration: BoxDecoration(
+ color: Settings.tacticalVioletTheme.card,
+ borderRadius: BorderRadius.circular(12),
+ border: Border.all(color: Settings.tacticalVioletTheme.border),
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ const AutoSaveButton(style: style),
+ EditorToolbarButton(
+ style: style,
+ tooltip: 'Export .ica',
+ onPressed: _exportStrategy,
+ icon: const Icon(LucideIcons.upload300),
+ ),
+ EditorToolbarButton(
+ style: style,
+ tooltip: 'Export video',
+ onPressed: _exportVideo,
+ icon: const Icon(LucideIcons.clapperboard300),
+ ),
+ EditorToolbarButton(
+ style: style,
+ tooltip: 'Screenshot',
+ onPressed: _captureScreenshot,
+ icon: _isCapturingScreenshot
+ ? SizedBox(
+ width: style.iconSize - 2,
+ height: style.iconSize - 2,
+ child: CircularProgressIndicator(
+ strokeWidth: 1.8,
+ valueColor: AlwaysStoppedAnimation(
+ Settings.tacticalVioletTheme.mutedForeground,
+ ),
+ ),
+ )
+ : const Icon(LucideIcons.camera300),
+ ),
+ const EditorToolbarDivider(),
+ EditorToolbarButton(
+ style: style,
+ tooltip: 'Settings',
+ onPressed: () {
+ showShadDialog(
+ context: context,
+ builder: (context) => const SettingsTab(),
+ );
+ },
+ icon: const Icon(LucideIcons.settings300),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ void _showDesktopOnlyToast() {
+ Settings.showToast(
+ message: 'This feature is only supported in the Windows version.',
+ backgroundColor: Settings.tacticalVioletTheme.destructive,
+ );
+ }
+
+ Future _exportStrategy() async {
+ if (kIsWeb) {
+ _showDesktopOnlyToast();
+ return;
+ }
+
+ await ref
+ .read(strategyProvider.notifier)
+ .exportFile(ref.read(strategyProvider).id);
+ }
+
+ void _exportVideo() {
+ if (kIsWeb) {
+ _showDesktopOnlyToast();
+ return;
+ }
+ showShadDialog(
+ context: context,
+ builder: (context) => const ExportVideoDialog(),
+ );
+ }
+
+ Future _captureScreenshot() async {
+ if (kIsWeb) {
+ _showDesktopOnlyToast();
+ return;
+ }
+ if (_isCapturingScreenshot) return;
+ setState(() => _isCapturingScreenshot = true);
+ CoordinateSystem.instance.setIsScreenshot(true);
+
+ final String id = ref.read(strategyProvider).id;
+
+ await ref.read(strategyProvider.notifier).forceSaveNow(id);
+
+ final newStrat = Hive.box(HiveBoxNames.strategiesBox)
+ .values
+ .where((StrategyData strategy) => strategy.id == id)
+ .firstOrNull;
+
+ if (newStrat == null) {
+ if (mounted) setState(() => _isCapturingScreenshot = false);
+ CoordinateSystem.instance.setIsScreenshot(false);
+ return;
+ }
+ final newController = ScreenshotController();
+ final currentPageID = ref.read(strategyProvider.notifier).activePageID;
+ final mapState = ref.read(mapProvider);
+
+ if (currentPageID == null) {
+ if (mounted) setState(() => _isCapturingScreenshot = false);
+ CoordinateSystem.instance.setIsScreenshot(false);
+ return;
+ }
+
+ final activePage = newStrat.pages.firstWhere(
+ (p) => p.id == currentPageID,
+ orElse: () => newStrat.pages.first,
+ );
+ final screenshotContainer = ProviderContainer();
+
+ try {
+ final screenshotView = ScreenshotView(
+ isAttack: activePage.isAttack,
+ mapValue: newStrat.mapData,
+ showSpawnBarrier: mapState.showSpawnBarrier,
+ showRegionNames: mapState.showRegionNames,
+ showUltOrbs: mapState.showUltOrbs,
+ agents: activePage.agentData,
+ abilities: activePage.abilityData,
+ text: activePage.textData,
+ images: activePage.imageData,
+ drawings: activePage.drawingData,
+ utilities: activePage.utilityData,
+ strategySettings: activePage.settings,
+ strategyState: ref.read(strategyProvider),
+ pageName: activePage.name,
+ lineUpGraph: activePage.lineUpGraph,
+ themeProfileId: newStrat.themeProfileId,
+ themeOverridePalette: newStrat.themeOverridePalette,
+ );
+ screenshotView.hydrateProviders(screenshotContainer);
+ final image = await newController.captureFromWidget(
+ targetSize: CoordinateSystem.screenShotSize,
+ wrapForOffscreenCapture(
+ screenshotView,
+ container: screenshotContainer,
+ ),
+ );
+ if (mounted) setState(() => _isCapturingScreenshot = false);
+ String? outputFile = await FilePicker.platform.saveFile(
+ type: FileType.custom,
+ dialogTitle: 'Please select an output file:',
+ fileName: "${ref.read(strategyProvider).stratName ?? "new image"}.png",
+ allowedExtensions: ['png'],
+ );
+ if (outputFile != null) {
+ final file = File(outputFile);
+ await file.writeAsBytes(image);
+ }
+ } catch (_) {
+ } finally {
+ screenshotContainer.dispose();
+ if (mounted && _isCapturingScreenshot) {
+ setState(() => _isCapturingScreenshot = false);
+ }
+ ref.read(screenshotProvider.notifier).setIsScreenShot(false);
+ CoordinateSystem.instance.setIsScreenshot(false);
+ ref
+ .read(drawingProvider.notifier)
+ .rebuildAllPaths(CoordinateSystem.instance);
+ }
+ }
+}
+
+/// One control in the editor toolbar. Glyphs are the 300 stroke weight: the
+/// default 2px Lucide stroke reads heavy in white at 18px, and muted grey
+/// vanishes against the card, so the weight carries the quietness instead. [icon] is any 18px glyph, so buttons can swap
+/// in a spinner without changing size.
+class EditorToolbarButton extends StatelessWidget {
+ const EditorToolbarButton({
+ super.key,
+ required this.style,
+ required this.tooltip,
+ required this.icon,
+ required this.onPressed,
+ this.enabled = true,
+ this.foregroundColor,
+ this.semanticsLabel,
+ });
+
+ final EditorToolbarButtonStyle style;
+ final String tooltip;
+ final Widget icon;
+ final VoidCallback? onPressed;
+ final bool enabled;
+
+ /// Overrides the resting color, e.g. destructive for a problem.
+ final Color? foregroundColor;
+ final String? semanticsLabel;
+
+ @override
+ Widget build(BuildContext context) {
+ const theme = Settings.tacticalVioletTheme;
+ final resting = foregroundColor ?? theme.foreground;
+ return Semantics(
+ label: semanticsLabel ?? tooltip,
+ button: true,
+ enabled: enabled,
+ onTap: enabled ? onPressed : null,
+ excludeSemantics: true,
+ child: ShadTooltip(
+ builder: (context) => Text(tooltip),
+ child: IconTheme(
+ data: IconThemeData(size: style.iconSize, color: resting),
+ child: ShadIconButton.ghost(
+ width: style.size,
+ height: style.size,
+ enabled: enabled,
+ foregroundColor: resting,
+ hoverForegroundColor: foregroundColor ?? theme.foreground,
+ hoverBackgroundColor: theme.accent,
+ onPressed: onPressed,
+ icon: icon,
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+/// A 1px hairline between groups of toolbar controls.
+class EditorToolbarDivider extends StatelessWidget {
+ const EditorToolbarDivider({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return Container(
+ width: 1,
+ height: 18,
+ margin: const EdgeInsets.symmetric(horizontal: 4),
+ color: Settings.tacticalVioletTheme.border,
+ );
+ }
+}
diff --git a/lib/widgets/folder_card.dart b/lib/widgets/folder_card.dart
index a631ea46..d95f8cf6 100644
--- a/lib/widgets/folder_card.dart
+++ b/lib/widgets/folder_card.dart
@@ -549,7 +549,7 @@ class _FolderCardState extends ConsumerState
if (isPinned) ...[
const SizedBox(width: 4),
Icon(
- Icons.push_pin,
+ LucideIcons.pin,
color: Colors.white.withValues(alpha: 0.78),
size: 13,
),
@@ -622,7 +622,7 @@ class _FolderCardState extends ConsumerState
highlightColor: Colors.white.withValues(alpha: 0.08),
onTap: _handleMenuButtonPressed,
child: Icon(
- Icons.more_vert,
+ LucideIcons.ellipsisVertical,
color: Colors.white.withValues(alpha: iconAlpha),
size: 16,
),
@@ -637,7 +637,7 @@ class _FolderCardState extends ConsumerState
final id = _folder.id;
return [
ShadContextMenuItem(
- leading: Icon(isPinned ? Icons.push_pin : Icons.push_pin_outlined),
+ leading: Icon(isPinned ? LucideIcons.pinOff : LucideIcons.pin),
child: Text(isPinned ? 'Unpin' : 'Pin'),
onPressed: () {
_closeMenus();
@@ -646,7 +646,7 @@ class _FolderCardState extends ConsumerState
},
),
ShadContextMenuItem(
- leading: const Icon(Icons.text_fields),
+ leading: const Icon(LucideIcons.pencil),
child: const Text('Edit'),
onPressed: () async {
_closeMenus();
@@ -660,7 +660,7 @@ class _FolderCardState extends ConsumerState
},
),
ShadContextMenuItem(
- leading: const Icon(Icons.file_upload),
+ leading: const Icon(LucideIcons.upload),
child: const Text('Export'),
onPressed: () async {
_closeMenus();
@@ -668,8 +668,10 @@ class _FolderCardState extends ConsumerState
},
),
ShadContextMenuItem(
- leading: const Icon(Icons.delete, color: Colors.redAccent),
- child: const Text('Delete', style: TextStyle(color: Colors.redAccent)),
+ leading: Icon(LucideIcons.trash2,
+ color: Settings.tacticalVioletTheme.destructive),
+ child: Text('Delete',
+ style: TextStyle(color: Settings.tacticalVioletTheme.destructive)),
onPressed: () async {
_closeMenus();
ConfirmAlertDialog.show(
@@ -844,7 +846,7 @@ class _CountBadge extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: [
if (folderCount > 0) ...[
- Icon(Icons.folder_outlined, size: 12, color: muted),
+ Icon(LucideIcons.folder, size: 12, color: muted),
const SizedBox(width: 2),
Text(
'$folderCount',
@@ -854,7 +856,7 @@ class _CountBadge extends StatelessWidget {
],
if (folderCount > 0 && strategyCount > 0) const SizedBox(width: 6),
if (strategyCount > 0) ...[
- Icon(Icons.description_outlined, size: 12, color: muted),
+ Icon(LucideIcons.fileText, size: 12, color: muted),
const SizedBox(width: 2),
Text(
'$strategyCount',
diff --git a/lib/widgets/folder_content.dart b/lib/widgets/folder_content.dart
index 5ec9a9aa..a7c0b46f 100644
--- a/lib/widgets/folder_content.dart
+++ b/lib/widgets/folder_content.dart
@@ -3,7 +3,6 @@ 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/folder_provider.dart';
import 'package:icarus/providers/library_context_menu_provider.dart';
import 'package:icarus/providers/pinned_items_provider.dart';
@@ -11,11 +10,11 @@ import 'package:icarus/providers/strategy_filter_provider.dart';
import 'package:icarus/providers/strategy_provider.dart';
import 'package:icarus/widgets/strategy_tile/strategy_tile.dart';
import 'package:icarus/widgets/custom_search_field.dart';
+import 'package:icarus/widgets/library_breadcrumb.dart';
import 'package:icarus/widgets/ica_drop_target.dart';
import 'package:icarus/widgets/drop_insertion_indicator.dart';
import 'package:icarus/widgets/folder_card.dart';
import 'package:icarus/widgets/hover_dot_grid.dart';
-import 'package:shadcn_ui/shadcn_ui.dart';
@visibleForTesting
bool strategyBelongsToVisibleFolder({
@@ -135,8 +134,6 @@ class FolderContent extends ConsumerWidget {
return Hive.box(HiveBoxNames.foldersBox).listenable();
});
- final TextEditingController searchController = TextEditingController();
-
@override
Widget build(BuildContext context, WidgetRef ref) {
// Move all your existing grid logic here from FolderView
@@ -157,75 +154,11 @@ 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: [
- ShadSelect(
- decoration: ShadDecoration(
- color: Settings.tacticalVioletTheme.card,
- shadows: const [Settings.cardForegroundBackdrop],
- ),
- initialValue:
- ref.watch(strategyFilterProvider).sortBy,
- selectedOptionBuilder: (context, value) => Text(
- StrategyFilterProvider.sortByLabels[value]!),
- options: [
- for (final sb in SortBy.values)
- ShadOption(
- value: sb,
- child: Text(
- StrategyFilterProvider.sortByLabels[sb]!),
- ),
- ],
- onChanged: (value) {
- ref
- .read(strategyFilterProvider.notifier)
- .setSortBy(value!);
- },
- ),
- ShadSelect(
- decoration: ShadDecoration(
- color: Settings.tacticalVioletTheme.card,
- shadows: const [Settings.cardForegroundBackdrop],
- ),
- initialValue:
- ref.watch(strategyFilterProvider).sortOrder,
- selectedOptionBuilder: (context, value) => Text(
- StrategyFilterProvider.sortOrderLabels[value]!),
- options: [
- for (final so in SortOrder.values)
- ShadOption(
- value: so,
- child: Text(StrategyFilterProvider
- .sortOrderLabels[so]!),
- ),
- ],
- onChanged: (value) {
- ref
- .read(strategyFilterProvider.notifier)
- .setSortOrder(value!);
- },
- ),
- ],
- ),
- SizedBox(
- height: 40,
- child: SearchTextField(
- controller: searchController,
- collapsedWidth: 40,
- expandedWidth: 250,
- compact: true,
- onChanged: (value) {},
- ),
- ),
- ],
+ if (folder != null)
+ Padding(
+ padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
+ child: LibraryBreadcrumb(folder: folder!),
),
- ),
Expanded(
child: ValueListenableBuilder>(
valueListenable: strategiesBoxListenable,
diff --git a/lib/widgets/folder_edit_dialog.dart b/lib/widgets/folder_edit_dialog.dart
index 0ce2b647..ed17f34e 100644
--- a/lib/widgets/folder_edit_dialog.dart
+++ b/lib/widgets/folder_edit_dialog.dart
@@ -74,7 +74,7 @@ class _FolderEditDialogState extends ConsumerState {
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
child: ShadButton(
- leading: const Icon(Icons.check),
+ leading: const Icon(LucideIcons.check),
onPressed: () async {
if (widget.folder != null) {
ref.read(folderProvider.notifier).editFolder(
diff --git a/lib/widgets/folder_navigator.dart b/lib/widgets/folder_navigator.dart
index 5da78ada..33a5fb99 100644
--- a/lib/widgets/folder_navigator.dart
+++ b/lib/widgets/folder_navigator.dart
@@ -15,10 +15,9 @@ import 'package:icarus/providers/update_status_provider.dart';
import 'package:icarus/services/app_error_reporter.dart';
import 'package:icarus/services/windows_desktop_update_controller.dart';
import 'package:icarus/strategy_view.dart';
-import 'package:icarus/widgets/current_path_bar.dart';
import 'package:icarus/widgets/desktop_update_dialog.dart';
import 'package:icarus/widgets/demo_dialog.dart';
-import 'package:icarus/widgets/demo_tag.dart';
+import 'package:icarus/widgets/library_title_strip.dart';
import 'package:icarus/widgets/dialogs/strategy/create_strategy_dialog.dart';
import 'package:icarus/widgets/dialogs/web_view_dialog.dart';
import 'package:icarus/widgets/folder_content.dart';
@@ -38,15 +37,11 @@ class _FolderNavigatorState extends ConsumerState {
bool _warnedOnce = false;
bool _hasPromptedUpdateDialog = false;
WindowsDesktopUpdateController? _desktopUpdaterController;
- final GlobalKey _importExportButtonKey = GlobalKey();
final ShadContextMenuController _backgroundMenuController =
ShadContextMenuController();
- final ShadPopoverController _importExportPopoverController =
- ShadPopoverController();
@override
void dispose() {
- _importExportPopoverController.dispose();
_backgroundMenuController.dispose();
_desktopUpdaterController?.dispose();
super.dispose();
@@ -118,10 +113,6 @@ class _FolderNavigatorState extends ConsumerState {
);
}
- void _toggleImportExportPopover() {
- _importExportPopoverController.toggle();
- }
-
Future handleImportIca() async {
if (kIsWeb) {
_showDesktopOnlyToast();
@@ -308,106 +299,34 @@ class _FolderNavigatorState extends ConsumerState {
return Stack(
children: [
Scaffold(
- appBar: AppBar(
- title: const 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: [
- ShadPopover(
- controller: _importExportPopoverController,
- padding: const EdgeInsets.all(8),
- anchor: const ShadAnchor(
- offset: Offset(0, 8),
- childAlignment: Alignment.topLeft,
- overlayAlignment: Alignment.bottomLeft,
+ body: Column(
+ children: [
+ LibraryTitleStrip(
+ onCreateStrategy: showCreateDialog,
+ onCreateFolder: showCreateFolderDialog,
+ onImportIca: handleImportIca,
+ onImportBackup: handleImportBackup,
+ onExportLibrary: handleExportLibrary,
+ ),
+ Expanded(
+ child: ShadContextMenuRegion(
+ controller: _backgroundMenuController,
+ items: [
+ ShadContextMenuItem(
+ leading: const Icon(LucideIcons.folderPlus),
+ onPressed: showCreateFolderDialog,
+ child: const Text('Create Folder'),
),
- 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: const Text(
- 'Import .ica',
- style: TextStyle(color: Colors.white),
- ),
- ),
- ShadButton.ghost(
- onPressed: handleImportBackup,
- mainAxisAlignment: MainAxisAlignment.start,
- leading: const Icon(
- Icons.archive_outlined,
- ),
- child: const Text('Import Backup',
- style: TextStyle(color: Colors.white)),
- ),
- ShadButton.ghost(
- onPressed: handleExportLibrary,
- mainAxisAlignment: MainAxisAlignment.start,
- leading: const Icon(
- Icons.backup_outlined,
- ),
- child: const Text('Export Library',
- style: TextStyle(color: Colors.white)),
- ),
- ],
- ),
- );
- },
- child: ShadButton.secondary(
- key: _importExportButtonKey,
- onPressed: _toggleImportExportPopover,
- leading: const Icon(Icons.import_export),
- trailing: const Icon(Icons.keyboard_arrow_down),
- child: const Text('Import / Export'),
+ ShadContextMenuItem(
+ leading: const Icon(LucideIcons.filePlus),
+ onPressed: showCreateDialog,
+ child: const Text('Create Strategy'),
),
- ),
- ShadButton.secondary(
- leading: const Icon(LucideIcons.folderPlus),
- onPressed: showCreateFolderDialog,
- child: const Text('Add Folder'),
- ),
- ShadButton(
- onPressed: showCreateDialog,
- leading: const Icon(Icons.add),
- child: const Text('Create Strategy'),
- ),
- ],
- )
- ],
- // ... your existing actions
- ),
- body: ShadContextMenuRegion(
- controller: _backgroundMenuController,
- items: [
- 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: FolderContent(folder: currentFolder),
+ ),
),
],
- child: FolderContent(folder: currentFolder),
),
),
if (_desktopUpdaterController != null)
diff --git a/lib/widgets/folder_pill.dart b/lib/widgets/folder_pill.dart
index dc9fcc18..f5b8bbf4 100644
--- a/lib/widgets/folder_pill.dart
+++ b/lib/widgets/folder_pill.dart
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
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_context_menu_provider.dart';
import 'package:icarus/providers/pinned_items_provider.dart';
@@ -278,7 +279,7 @@ class _FolderPillState extends ConsumerState
if (isPinned) ...[
const SizedBox(width: 6),
Icon(
- Icons.push_pin,
+ LucideIcons.pin,
color: Colors.white.withValues(alpha: 0.78),
size: 14,
),
@@ -342,7 +343,7 @@ class _FolderPillState extends ConsumerState
highlightColor: Colors.white.withValues(alpha: 0.08),
onTap: _handleMenuButtonPressed,
child: Icon(
- Icons.more_vert,
+ LucideIcons.ellipsisVertical,
color: Colors.white.withValues(alpha: iconAlpha),
size: 18,
),
@@ -357,7 +358,7 @@ class _FolderPillState extends ConsumerState
final id = widget.folder.id;
return [
ShadContextMenuItem(
- leading: Icon(isPinned ? Icons.push_pin : Icons.push_pin_outlined),
+ leading: Icon(isPinned ? LucideIcons.pinOff : LucideIcons.pin),
child: Text(isPinned ? 'Unpin' : 'Pin'),
onPressed: () {
_closeMenus();
@@ -366,7 +367,7 @@ class _FolderPillState extends ConsumerState
},
),
ShadContextMenuItem(
- leading: const Icon(Icons.text_fields),
+ leading: const Icon(LucideIcons.pencil),
child: const Text('Edit'),
onPressed: () async {
_closeMenus();
@@ -380,7 +381,7 @@ class _FolderPillState extends ConsumerState
},
),
ShadContextMenuItem(
- leading: const Icon(Icons.file_upload),
+ leading: const Icon(LucideIcons.upload),
child: const Text('Export'),
onPressed: () async {
_closeMenus();
@@ -390,8 +391,10 @@ class _FolderPillState extends ConsumerState
},
),
ShadContextMenuItem(
- leading: const Icon(Icons.delete, color: Colors.redAccent),
- child: const Text('Delete', style: TextStyle(color: Colors.redAccent)),
+ leading: Icon(LucideIcons.trash2,
+ color: Settings.tacticalVioletTheme.destructive),
+ child: Text('Delete',
+ style: TextStyle(color: Settings.tacticalVioletTheme.destructive)),
onPressed: () async {
_closeMenus();
ConfirmAlertDialog.show(
diff --git a/lib/widgets/ica_drop_target.dart b/lib/widgets/ica_drop_target.dart
index 6547c355..7dfc4fd2 100644
--- a/lib/widgets/ica_drop_target.dart
+++ b/lib/widgets/ica_drop_target.dart
@@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:icarus/const/settings.dart';
import 'package:icarus/providers/strategy_provider.dart';
import 'package:icarus/services/app_error_reporter.dart';
+import 'package:shadcn_ui/shadcn_ui.dart';
String buildImportSummaryMessage(ImportBatchResult result) {
final skippedCount = result.issues.length;
@@ -143,7 +144,7 @@ class _CustomDropTargetState extends ConsumerState {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
- Icon(Icons.download, size: 60),
+ Icon(LucideIcons.download, size: 60),
SizedBox(
height: 10,
),
diff --git a/lib/widgets/image_drop_target.dart b/lib/widgets/image_drop_target.dart
index d0bb8344..ec65f673 100644
--- a/lib/widgets/image_drop_target.dart
+++ b/lib/widgets/image_drop_target.dart
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:icarus/const/settings.dart';
import 'package:icarus/providers/image_provider.dart';
+import 'package:shadcn_ui/shadcn_ui.dart';
class ImageDropTarget extends ConsumerStatefulWidget {
const ImageDropTarget({super.key, required this.child});
@@ -68,7 +69,7 @@ class _ImageDropTargetState extends ConsumerState {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
- Icon(Icons.download, size: 60),
+ Icon(LucideIcons.download, size: 60),
SizedBox(
height: 10,
),
diff --git a/lib/widgets/library_breadcrumb.dart b/lib/widgets/library_breadcrumb.dart
new file mode 100644
index 00000000..51e8522a
--- /dev/null
+++ b/lib/widgets/library_breadcrumb.dart
@@ -0,0 +1,133 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:icarus/const/settings.dart';
+import 'package:icarus/providers/folder_provider.dart';
+import 'package:icarus/providers/strategy_provider.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 the
+/// library'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 folders = ref.read(folderProvider.notifier);
+ final pathFolders = folders
+ .getFullPathIDs(folder)
+ .map(folders.findFolderByID)
+ .whereType()
+ .toList(growable: false);
+ final parent =
+ pathFolders.length >= 2 ? pathFolders[pathFolders.length - 2] : null;
+
+ void goToRoot() => ref.read(folderProvider.notifier).updateID(null);
+
+ // Same card as the editor toolbar, so the path reads as hardware on the
+ // bench instead of text floating on the dot grid.
+ return Align(
+ alignment: Alignment.centerLeft,
+ child: Container(
+ key: const ValueKey('library-breadcrumb'),
+ height: 36,
+ padding: const EdgeInsets.fromLTRB(4, 0, 12, 0),
+ decoration: BoxDecoration(
+ color: Settings.tacticalVioletTheme.card,
+ borderRadius: BorderRadius.circular(12),
+ border: Border.all(color: Settings.tacticalVioletTheme.border),
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ ShadIconButton.ghost(
+ width: 28,
+ height: 28,
+ foregroundColor: Settings.tacticalVioletTheme.mutedForeground,
+ hoverForegroundColor: Settings.tacticalVioletTheme.foreground,
+ onPressed: () {
+ if (parent == null) {
+ goToRoot();
+ } else {
+ ref.read(folderProvider.notifier).updateID(parent.id);
+ }
+ },
+ icon: const Icon(LucideIcons.chevronLeft300, size: 18),
+ ),
+ const SizedBox(width: 4),
+ ShadBreadcrumb(
+ lastItemTextColor: Settings.tacticalVioletTheme.foreground,
+ textStyle: ShadTheme.of(context).textTheme.small,
+ children: [
+ FolderTab(
+ folder: null,
+ label: 'My Library',
+ onOpen: goToRoot,
+ ),
+ for (int i = 0; i < pathFolders.length; i++)
+ FolderTab(
+ folder: pathFolders[i],
+ isActive: i == pathFolders.length - 1,
+ onOpen: () => ref
+ .read(folderProvider.notifier)
+ .updateID(pathFolders[i].id),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+/// One crumb. Also a drop target: dragging a strategy or folder onto it moves
+/// the item there.
+class FolderTab extends ConsumerWidget {
+ const FolderTab({
+ super.key,
+ required this.folder,
+ required this.onOpen,
+ this.label,
+ this.isActive = false,
+ });
+
+ /// Null for the root crumb.
+ final Folder? folder;
+ final VoidCallback onOpen;
+ final String? label;
+ final bool isActive;
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ return ShadBreadcrumbLink(
+ textStyle: ShadTheme.of(context).textTheme.small,
+ normalColor: isActive ? Settings.tacticalVioletTheme.foreground : null,
+ onPressed: onOpen,
+ child: DragTarget(
+ onAcceptWithDetails: (details) {
+ final item = details.data;
+ if (item is StrategyItem) {
+ ref.read(strategyProvider.notifier).moveToFolder(
+ strategyID: item.strategy.id,
+ parentID: folder?.id,
+ );
+ } else if (item is FolderItem) {
+ ref.read(folderProvider.notifier).moveToFolder(
+ folderID: item.folder.id,
+ parentID: folder?.id,
+ );
+ }
+ },
+ 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_title_strip.dart b/lib/widgets/library_title_strip.dart
new file mode 100644
index 00000000..7be4003f
--- /dev/null
+++ b/lib/widgets/library_title_strip.dart
@@ -0,0 +1,394 @@
+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/folder_provider.dart';
+import 'package:icarus/providers/strategy_filter_provider.dart';
+import 'package:icarus/widgets/custom_search_field.dart';
+import 'package:icarus/widgets/demo_tag.dart';
+import 'package:icarus/widgets/window_chrome.dart';
+import 'package:shadcn_ui/shadcn_ui.dart';
+
+const double _controlHeight = 28;
+// Tabs sit apart so a selected and a hovered background never touch.
+const double _tabGap = 4;
+// Action menus hug their labels.
+const double _sortMenuWidth = 168;
+const double _newMenuWidth = 140;
+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 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();
+
+ @override
+ void dispose() {
+ _sortController.dispose();
+ _newController.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return AppWindowStrip(
+ child: Row(
+ children: [
+ const IcarusWordmark(),
+ const SizedBox(width: 6),
+ _TabButton(
+ key: const ValueKey('library-tab-library'),
+ icon: LucideIcons.folder,
+ label: 'My Library',
+ semanticsLabel: 'My Library',
+ selected: true,
+ onTap: () => ref.read(folderProvider.notifier).updateID(null),
+ ),
+ const SizedBox(width: _tabGap),
+ // Shared and Community have nowhere to go yet; they hold their
+ // place so the library's shape does not move when they land.
+ const _TabButton(
+ key: ValueKey('library-tab-shared'),
+ icon: LucideIcons.users,
+ label: 'Shared',
+ semanticsLabel: 'Shared library',
+ selected: false,
+ dimmed: true,
+ ),
+ const SizedBox(width: _tabGap),
+ const _TabButton(
+ key: ValueKey('library-tab-community'),
+ icon: LucideIcons.globe,
+ label: 'Community',
+ semanticsLabel: 'Community library',
+ selected: false,
+ dimmed: true,
+ ),
+ if (kIsWeb) ...[
+ const SizedBox(width: 8),
+ const DemoTag(),
+ ],
+ const Expanded(
+ child: WindowDragArea(
+ key: ValueKey('library-window-drag-area'),
+ child: SizedBox.expand(),
+ ),
+ ),
+ 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),
+ _buildNewMenu(),
+ 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 ? LucideIcons.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: LucideIcons.filePlus,
+ 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: LucideIcons.fileDown,
+ label: 'Import .ica',
+ onPressed: widget.onImportIca,
+ ),
+ _MenuItem(
+ menu: _newController,
+ icon: LucideIcons.archiveRestore,
+ label: 'Import Backup',
+ onPressed: widget.onImportBackup,
+ ),
+ _MenuItem(
+ menu: _newController,
+ icon: LucideIcons.archive,
+ 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(LucideIcons.plus, size: 16),
+ trailing: const Icon(LucideIcons.chevronDown, size: 14),
+ child: const Text('New'),
+ ),
+ );
+ }
+}
+
+class _TabButton extends StatelessWidget {
+ const _TabButton({
+ super.key,
+ required this.icon,
+ required this.label,
+ required this.semanticsLabel,
+ required this.selected,
+ this.onTap,
+ this.dimmed = false,
+ });
+
+ final IconData icon;
+ final String label;
+ final String semanticsLabel;
+ final bool selected;
+ final bool dimmed;
+
+ /// Null while the tab has nowhere to go; the button reads as disabled.
+ final VoidCallback? onTap;
+
+ @override
+ Widget build(BuildContext context) {
+ const theme = Settings.tacticalVioletTheme;
+ final foreground = selected ? theme.foreground : theme.mutedForeground;
+ final button = Semantics(
+ label: semanticsLabel,
+ button: true,
+ selected: selected,
+ enabled: onTap != null,
+ onTap: onTap,
+ child: Opacity(
+ opacity: dimmed ? 0.45 : 1,
+ child: ShadButton.ghost(
+ height: _controlHeight,
+ padding: const EdgeInsets.symmetric(horizontal: 8),
+ gap: 6,
+ cursor: onTap == null ? SystemMouseCursors.basic : null,
+ 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),
+ ),
+ ),
+ ),
+ );
+ if (onTap != null) return button;
+ return ShadTooltip(
+ builder: (context) => const Text('Coming soon'),
+ child: button,
+ );
+ }
+}
+
+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,
+ color: icon == LucideIcons.check
+ ? Settings.tacticalVioletTheme.primary
+ : Settings.tacticalVioletTheme.mutedForeground,
+ ),
+ ),
+ 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/line_up_media_carousel.dart b/lib/widgets/line_up_media_carousel.dart
index dbddb05b..6f1a11a6 100644
--- a/lib/widgets/line_up_media_carousel.dart
+++ b/lib/widgets/line_up_media_carousel.dart
@@ -209,7 +209,7 @@ class _LineUpMediaPagesState extends ConsumerState
if (!file.existsSync()) {
return const Center(
- child: Icon(Icons.broken_image, color: Colors.white));
+ child: Icon(LucideIcons.imageOff, color: Colors.white));
}
return InteractiveViewer(
diff --git a/lib/widgets/map_selector.dart b/lib/widgets/map_selector.dart
index ca20e482..fe179a82 100644
--- a/lib/widgets/map_selector.dart
+++ b/lib/widgets/map_selector.dart
@@ -6,6 +6,7 @@ import 'package:icarus/const/settings.dart';
import 'package:icarus/providers/map_provider.dart';
import 'package:icarus/providers/strategy_provider.dart';
import 'package:icarus/widgets/map_tile.dart';
+import 'package:shadcn_ui/shadcn_ui.dart';
class MapSelector extends ConsumerStatefulWidget {
const MapSelector({super.key});
@@ -17,7 +18,7 @@ class MapSelector extends ConsumerStatefulWidget {
class _MapSelectorState extends ConsumerState {
static const double _cardWidth = 262;
static const double _cardHeight = 65;
- static const double _outerRadius = 10;
+ static const double _outerRadius = 12;
static const double _innerGap = 4;
static const double _innerRadius = _outerRadius - _innerGap;
static const double _sideToggleWidth = 66;
@@ -76,10 +77,7 @@ class _MapSelectorState extends ConsumerState {
decoration: BoxDecoration(
color: Settings.tacticalVioletTheme.card,
borderRadius: const BorderRadius.all(Radius.circular(_outerRadius)),
- border: Border.all(
- color: Settings.tacticalVioletTheme.border,
- width: 2,
- ),
+ border: Border.all(color: Settings.tacticalVioletTheme.border),
),
width: _cardWidth,
height: _cardHeight,
@@ -222,7 +220,7 @@ class _MapSelectorState extends ConsumerState {
Icon(
(ref.watch(mapProvider).isAttack)
? CustomIcons.sword
- : Icons.shield,
+ : LucideIcons.shield,
size: 20,
color: (ref.watch(mapProvider).isAttack)
? Colors.redAccent
diff --git a/lib/widgets/numeric_drag_input.dart b/lib/widgets/numeric_drag_input.dart
index 43aa1de7..1ec47759 100644
--- a/lib/widgets/numeric_drag_input.dart
+++ b/lib/widgets/numeric_drag_input.dart
@@ -321,7 +321,7 @@ class _NumericDragInputState extends State {
child: Padding(
padding: trailingPadding,
child: Icon(
- Icons.drag_indicator,
+ LucideIcons.gripVertical,
size: widget.dragIconSize,
color: _isDragging
? colorScheme.primary
diff --git a/lib/widgets/pages_bar.dart b/lib/widgets/pages_bar.dart
index 5d423ccb..f5e561cc 100644
--- a/lib/widgets/pages_bar.dart
+++ b/lib/widgets/pages_bar.dart
@@ -216,7 +216,7 @@ class _PagesBarState extends ConsumerState {
),
ShadButton(
onPressed: () => Navigator.of(ctx).pop(controller.text.trim()),
- leading: const Icon(Icons.text_fields),
+ leading: const Icon(LucideIcons.type, size: 16),
child: const Text("Rename"),
),
],
@@ -398,7 +398,7 @@ class _CollapsedPill extends StatelessWidget {
children: [
const SizedBox(width: _pagesBarControlInset),
_SquareIconButton(
- icon: Icons.add,
+ icon: LucideIcons.plus,
onTap: onAdd,
tooltip: "Add page",
color: Settings.tacticalVioletTheme.primary,
@@ -421,7 +421,8 @@ class _CollapsedPill extends StatelessWidget {
padding: EdgeInsets.zero,
foregroundColor: Colors.white,
onPressed: onToggle,
- icon: const Icon(Icons.keyboard_arrow_down, color: Colors.white),
+ icon: const Icon(LucideIcons.chevronDown,
+ size: 20, color: Colors.white),
decoration: ShadDecoration(
border: ShadBorder(
radius: BorderRadius.circular(_pagesBarInnerButtonRadius),
@@ -601,7 +602,7 @@ class _ExpandedPanel extends ConsumerWidget {
children: [
const SizedBox(width: _pagesBarControlInset),
_SquareIconButton(
- icon: Icons.add,
+ icon: LucideIcons.plus,
onTap: onAdd,
tooltip: "Add page",
color: Settings.tacticalVioletTheme.primary,
@@ -614,8 +615,8 @@ class _ExpandedPanel extends ConsumerWidget {
padding: EdgeInsets.zero,
foregroundColor: Colors.white,
onPressed: onCollapse,
- icon:
- const Icon(Icons.keyboard_arrow_up, color: Colors.white),
+ icon: const Icon(LucideIcons.chevronUp,
+ size: 20, color: Colors.white),
decoration: ShadDecoration(
border: ShadBorder(
radius: BorderRadius.circular(
@@ -967,7 +968,7 @@ class _SquareIconButton extends StatelessWidget {
backgroundColor: color,
hoverBackgroundColor: color,
foregroundColor: Colors.white,
- icon: Icon(icon),
+ icon: Icon(icon, size: 20),
width: _pagesBarControlSize,
height: _pagesBarControlSize,
padding: EdgeInsets.zero,
diff --git a/lib/widgets/save_and_load_button.dart b/lib/widgets/save_and_load_button.dart
deleted file mode 100644
index c74e5b87..00000000
--- a/lib/widgets/save_and_load_button.dart
+++ /dev/null
@@ -1,209 +0,0 @@
-import 'dart:io';
-
-import 'package:file_picker/file_picker.dart';
-import 'package:flutter/foundation.dart';
-import 'package:flutter/material.dart';
-import 'package:flutter_riverpod/flutter_riverpod.dart';
-import 'package:hive_ce/hive.dart';
-import 'package:icarus/const/coordinate_system.dart';
-import 'package:icarus/const/hive_boxes.dart';
-import 'package:icarus/const/settings.dart';
-import 'package:icarus/providers/drawing_provider.dart';
-import 'package:icarus/providers/map_provider.dart';
-import 'package:icarus/providers/screenshot_provider.dart';
-import 'package:icarus/providers/strategy_provider.dart';
-import 'package:icarus/screenshot/offscreen_capture.dart';
-import 'package:icarus/screenshot/screenshot_view.dart';
-import 'package:icarus/widgets/dialogs/export_video_dialog.dart';
-import 'package:icarus/widgets/settings_tab.dart';
-import 'package:icarus/widgets/strategy_save_icon_button.dart';
-import 'package:screenshot/screenshot.dart';
-import 'package:shadcn_ui/shadcn_ui.dart';
-
-class SaveAndLoadButton extends ConsumerStatefulWidget {
- const SaveAndLoadButton({super.key});
-
- @override
- ConsumerState createState() =>
- _SaveAndLoadButtonState();
-}
-
-class _SaveAndLoadButtonState extends ConsumerState {
- bool _isLoading = false;
- @override
- Widget build(BuildContext context) {
- return Padding(
- padding: const EdgeInsets.all(8.0),
- child: Row(
- children: [
- ShadTooltip(
- builder: (context) => const Text("Settings"),
- child: ShadIconButton.ghost(
- foregroundColor: Colors.white,
- onPressed: () async {
- showShadDialog(
- context: context,
- builder: (context) => const SettingsTab(),
- );
- },
- icon: const Icon(Icons.settings),
- ),
- ),
- const AutoSaveButton(),
- ShadTooltip(
- builder: (context) => const Text("Export"),
- child: ShadIconButton.ghost(
- foregroundColor: Colors.white,
- onPressed: () async {
- if (kIsWeb) {
- Settings.showToast(
- message:
- 'This feature is only supported in the Windows version.',
- backgroundColor: Settings.tacticalVioletTheme.destructive,
- );
- return;
- }
-
- await ref
- .read(strategyProvider.notifier)
- .exportFile(ref.read(strategyProvider).id);
- },
- icon: const Icon(Icons.file_upload),
- ),
- ),
- ShadTooltip(
- builder: (context) => const Text("Export Video"),
- child: ShadIconButton.ghost(
- foregroundColor: Colors.white,
- onPressed: () async {
- if (kIsWeb) {
- Settings.showToast(
- message:
- 'This feature is only supported in the Windows version.',
- backgroundColor: Settings.tacticalVioletTheme.destructive,
- );
- return;
- }
- showShadDialog(
- context: context,
- builder: (context) => const ExportVideoDialog(),
- );
- },
- icon: const Icon(Icons.movie_outlined),
- ),
- ),
- ShadTooltip(
- builder: (context) => const Text("Screenshot"),
- child: ShadIconButton.ghost(
- foregroundColor: Colors.white,
- onPressed: () async {
- if (kIsWeb) {
- Settings.showToast(
- message:
- 'This feature is only supported in the Windows version.',
- backgroundColor: Settings.tacticalVioletTheme.destructive,
- );
- return;
- }
- if (_isLoading) return;
- setState(() {
- _isLoading = true;
- });
- CoordinateSystem.instance.setIsScreenshot(true);
-
- final String id = ref.read(strategyProvider).id;
-
- await ref.read(strategyProvider.notifier).forceSaveNow(id);
-
- final newStrat =
- Hive.box(HiveBoxNames.strategiesBox)
- .values
- .where((StrategyData strategy) {
- return strategy.id == id;
- }).firstOrNull;
-
- if (newStrat == null) {
- return;
- }
- final newController = ScreenshotController();
- final currentPageID =
- ref.read(strategyProvider.notifier).activePageID;
- final mapState = ref.read(mapProvider);
-
- if (currentPageID == null) return;
-
- final activePage = newStrat.pages.firstWhere(
- (p) => p.id == currentPageID,
- orElse: () => newStrat.pages.first,
- );
- final screenshotContainer = ProviderContainer();
-
- try {
- final screenshotView = ScreenshotView(
- isAttack: activePage.isAttack,
- mapValue: newStrat.mapData,
- showSpawnBarrier: mapState.showSpawnBarrier,
- showRegionNames: mapState.showRegionNames,
- showUltOrbs: mapState.showUltOrbs,
- agents: activePage.agentData,
- abilities: activePage.abilityData,
- text: activePage.textData,
- images: activePage.imageData,
- drawings: activePage.drawingData,
- utilities: activePage.utilityData,
- strategySettings: activePage.settings,
- strategyState: ref.read(strategyProvider),
- pageName: activePage.name,
- lineUpGraph: activePage.lineUpGraph,
- themeProfileId: newStrat.themeProfileId,
- themeOverridePalette: newStrat.themeOverridePalette,
- );
- screenshotView.hydrateProviders(screenshotContainer);
- final image = await newController.captureFromWidget(
- targetSize: CoordinateSystem.screenShotSize,
- wrapForOffscreenCapture(
- screenshotView,
- container: screenshotContainer,
- ),
- );
- setState(() {
- _isLoading = false;
- });
- String? outputFile = await FilePicker.platform.saveFile(
- type: FileType.custom,
- dialogTitle: 'Please select an output file:',
- fileName:
- "${ref.read(strategyProvider).stratName ?? "new image"}.png",
- allowedExtensions: ['png'],
- );
- if (outputFile != null) {
- final file = File(outputFile);
- await file.writeAsBytes(image);
- }
- } catch (_) {
- } finally {
- screenshotContainer.dispose();
- ref.read(screenshotProvider.notifier).setIsScreenShot(false);
- CoordinateSystem.instance.setIsScreenshot(false);
- ref
- .read(drawingProvider.notifier)
- .rebuildAllPaths(CoordinateSystem.instance);
- }
- // CoordinateSystem.instance.setIsScreenshot(false);
- },
- icon: _isLoading
- ? const SizedBox(
- height: 18,
- width: 18,
- child: CircularProgressIndicator(
- color: Colors.white,
- ),
- )
- : const Icon(Icons.camera_alt_outlined),
- ),
- ),
- ],
- ),
- );
- }
-}
diff --git a/lib/widgets/settings_tab.dart b/lib/widgets/settings_tab.dart
index 71c4a7fb..56628b29 100644
--- a/lib/widgets/settings_tab.dart
+++ b/lib/widgets/settings_tab.dart
@@ -193,7 +193,7 @@ class _StrategySettingsSections extends ConsumerWidget {
child: Column(
children: [
_SettingsSliderTile(
- icon: Icons.person_pin_circle_outlined,
+ icon: LucideIcons.personStanding,
iconColor: Settings.settingsAgentAccent,
title: "Agent markers",
description:
@@ -221,7 +221,7 @@ class _StrategySettingsSections extends ConsumerWidget {
},
),
_SettingsSliderTile(
- icon: Icons.auto_awesome_outlined,
+ icon: LucideIcons.sparkles,
iconColor: Settings.settingsAbilityAccent,
title: "Ability markers",
description:
@@ -249,7 +249,7 @@ class _StrategySettingsSections extends ConsumerWidget {
},
),
_SettingsToggleTile(
- icon: Icons.contrast_outlined,
+ icon: LucideIcons.contrast,
iconColor: Settings.settingsNeutralAccent,
title: "Neutral team marker colors",
description:
@@ -305,7 +305,7 @@ class _GlobalSettingsSections extends ConsumerWidget {
child: Column(
children: [
_SettingsSliderTile(
- icon: Icons.person_pin_circle_outlined,
+ icon: LucideIcons.personStanding,
iconColor: Settings.settingsAgentAccent,
title: "Default agent markers",
description:
@@ -322,7 +322,7 @@ class _GlobalSettingsSections extends ConsumerWidget {
},
),
_SettingsSliderTile(
- icon: Icons.auto_awesome_outlined,
+ icon: LucideIcons.sparkles,
iconColor: Settings.settingsAbilityAccent,
title: "Default ability markers",
description:
@@ -339,7 +339,7 @@ class _GlobalSettingsSections extends ConsumerWidget {
},
),
_SettingsToggleTile(
- icon: Icons.contrast_outlined,
+ icon: LucideIcons.contrast,
iconColor: Settings.settingsNeutralAccent,
title: "Neutral marker colors by default",
description:
@@ -364,7 +364,7 @@ class _GlobalSettingsSections extends ConsumerWidget {
child: Column(
children: [
_SettingsToggleTile(
- icon: Icons.save_outlined,
+ icon: LucideIcons.save,
iconColor: Settings.settingsPersistenceAccent,
title: "Autosave",
description:
@@ -380,7 +380,7 @@ class _GlobalSettingsSections extends ConsumerWidget {
},
),
_SettingsToggleTile(
- icon: Icons.sports_esports_outlined,
+ icon: LucideIcons.gamepad2,
iconColor: Settings.settingsDiscordAccent,
title: "Discord Rich Presence",
description:
@@ -402,7 +402,7 @@ class _GlobalSettingsSections extends ConsumerWidget {
child: Column(
children: [
_SettingsToggleTile(
- icon: Icons.grid_on_rounded,
+ icon: LucideIcons.grid3x3,
iconColor: Settings.settingsMapAccent,
title: "Spawn barriers",
description:
@@ -413,7 +413,7 @@ class _GlobalSettingsSections extends ConsumerWidget {
},
),
_SettingsToggleTile(
- icon: Icons.location_on_outlined,
+ icon: LucideIcons.mapPin,
iconColor: Settings.settingsMapAccent,
title: "Region names",
description: "Show map callout names directly on the canvas.",
@@ -423,7 +423,7 @@ class _GlobalSettingsSections extends ConsumerWidget {
},
),
_SettingsToggleTile(
- icon: Icons.radio_button_checked_outlined,
+ icon: LucideIcons.circleDot,
iconColor: Settings.settingsMapAccent,
title: "Ultimate orbs",
description: "Display orb pickup markers on supported maps.",
@@ -440,7 +440,7 @@ class _GlobalSettingsSections extends ConsumerWidget {
key: sectionKeys[_SettingsSection.globalPrivacy],
title: "Privacy",
child: _SettingsToggleTile(
- icon: Icons.analytics_outlined,
+ icon: LucideIcons.chartColumn,
iconColor: Settings.settingsPersistenceAccent,
title: "Anonymous analytics",
description:
@@ -512,7 +512,7 @@ class _ShortcutSettingsSectionState
const SizedBox(width: 12),
ShadButton.secondary(
size: ShadButtonSize.sm,
- leading: const Icon(Icons.restart_alt_outlined, size: 15),
+ leading: const Icon(LucideIcons.rotateCcw, size: 15),
onPressed: customBindings.isEmpty
? null
: () {
@@ -632,7 +632,7 @@ class _ShortcutSearchField extends StatelessWidget {
fontSize: 13,
),
prefixIcon: Icon(
- Icons.search,
+ LucideIcons.search,
size: 17,
color: Settings.tacticalVioletTheme.mutedForeground,
),
@@ -752,7 +752,7 @@ class _ShortcutBindingRow extends StatelessWidget {
ShadTooltip(
builder: (_) => const Text("Reset to default"),
child: ShadIconButton.ghost(
- icon: const Icon(Icons.undo_outlined, size: 15),
+ icon: const Icon(LucideIcons.undo2, size: 15),
onPressed: onReset,
),
)
@@ -941,7 +941,7 @@ class _ShortcutCaptureFieldState extends State<_ShortcutCaptureField>
child: Row(
children: [
Icon(
- Icons.keyboard_alt_outlined,
+ LucideIcons.keyboard,
size: 17,
color: hasDuplicate
? Settings.tacticalVioletTheme.destructive
@@ -1042,13 +1042,13 @@ class _SettingsNavigationRail extends StatelessWidget {
const _SettingsNavHeader(label: "Current strategy"),
const SizedBox(height: 4),
_SettingsNavItem(
- icon: Icons.tune_outlined,
+ icon: LucideIcons.slidersHorizontal,
label: "Object styling",
isSelected: selectedSection == _SettingsSection.strategyObjects,
onTap: () => onSectionSelected(_SettingsSection.strategyObjects),
),
_SettingsNavItem(
- icon: Icons.palette_outlined,
+ icon: LucideIcons.palette,
label: "Map theme",
isSelected: selectedSection == _SettingsSection.strategyMapTheme,
onTap: () => onSectionSelected(_SettingsSection.strategyMapTheme),
@@ -1057,32 +1057,32 @@ class _SettingsNavigationRail extends StatelessWidget {
const _SettingsNavHeader(label: "App-wide"),
const SizedBox(height: 4),
_SettingsNavItem(
- icon: Icons.auto_fix_high_outlined,
+ icon: LucideIcons.wandSparkles,
label: "Defaults",
isSelected: selectedSection == _SettingsSection.globalDefaults,
onTap: () => onSectionSelected(_SettingsSection.globalDefaults),
),
_SettingsNavItem(
- icon: Icons.save_outlined,
+ icon: LucideIcons.save,
label: "Autosave",
isSelected: selectedSection == _SettingsSection.globalSaving,
onTap: () => onSectionSelected(_SettingsSection.globalSaving),
),
_SettingsNavItem(
- icon: Icons.map_outlined,
+ icon: LucideIcons.map,
label: "Map layers",
isSelected: selectedSection == _SettingsSection.globalMapVisibility,
onTap: () =>
onSectionSelected(_SettingsSection.globalMapVisibility),
),
_SettingsNavItem(
- icon: Icons.privacy_tip_outlined,
+ icon: LucideIcons.shield,
label: "Privacy",
isSelected: selectedSection == _SettingsSection.globalPrivacy,
onTap: () => onSectionSelected(_SettingsSection.globalPrivacy),
),
_SettingsNavItem(
- icon: Icons.keyboard_alt_outlined,
+ icon: LucideIcons.keyboard,
label: "Keybinds",
isSelected: selectedSection == _SettingsSection.shortcuts,
onTap: () => onSectionSelected(_SettingsSection.shortcuts),
diff --git a/lib/widgets/sidebar_widgets/agent_dragable.dart b/lib/widgets/sidebar_widgets/agent_dragable.dart
index 9fadcd49..0f972e14 100644
--- a/lib/widgets/sidebar_widgets/agent_dragable.dart
+++ b/lib/widgets/sidebar_widgets/agent_dragable.dart
@@ -142,8 +142,8 @@ class _AgentDragableState extends ConsumerState
DateTime.now().isAfter(_starOffEnabledAt);
final iconData = canShowStarOff
? LucideIcons.starOff
- : (isFavorite ? Icons.star_rounded : LucideIcons.star);
- final iconSize = iconData == Icons.star_rounded ? 18.5 : 16.0;
+ : (isFavorite ? LucideIcons.star600 : LucideIcons.star);
+ const iconSize = 16.0;
final iconColor = isFavorite
? (canShowStarOff ? const Color(0xFFE53935) : const Color(0xFFFF9800))
: (_isStarHovered ? const Color(0xFFFF9800) : const Color(0xFF9AA0A6));
diff --git a/lib/widgets/sidebar_widgets/custom_shape_tools.dart b/lib/widgets/sidebar_widgets/custom_shape_tools.dart
index abac7181..70cac0c5 100644
--- a/lib/widgets/sidebar_widgets/custom_shape_tools.dart
+++ b/lib/widgets/sidebar_widgets/custom_shape_tools.dart
@@ -94,12 +94,12 @@ class _CustomShapeToolsState extends ConsumerState {
spacing: 4,
children: [
SelectableIconButton(
- icon: const Icon(Icons.circle_outlined, size: 20),
+ icon: const Icon(LucideIcons.circle, size: 20),
isSelected: _shape == _CustomShapeKind.circle,
onPressed: () => setState(() => _shape = _CustomShapeKind.circle),
),
SelectableIconButton(
- icon: const Icon(Icons.crop_square, size: 20),
+ icon: const Icon(LucideIcons.square, size: 20),
isSelected: _shape == _CustomShapeKind.rectangle,
onPressed: () =>
setState(() => _shape = _CustomShapeKind.rectangle),
@@ -178,7 +178,7 @@ class _CustomShapeToolsState extends ConsumerState {
onChanged: (v) => setState(() => _opacityPercent = v.round()),
min: 5,
max: 80,
- leading: Icon(Icons.opacity,
+ leading: Icon(LucideIcons.droplet,
color: Settings.tacticalVioletTheme.mutedForeground),
// label: 'O',
hintText: 'Enter Opacity',
diff --git a/lib/widgets/sidebar_widgets/delete_options.dart b/lib/widgets/sidebar_widgets/delete_options.dart
index 779f5513..bc737b60 100644
--- a/lib/widgets/sidebar_widgets/delete_options.dart
+++ b/lib/widgets/sidebar_widgets/delete_options.dart
@@ -25,32 +25,32 @@ class DeleteOptions extends ConsumerWidget {
static const List<_DeleteOptionData> _options = [
_DeleteOptionData(
group: ActionGroup.agent,
- icon: Icons.person,
+ icon: LucideIcons.user,
label: 'Agents',
),
_DeleteOptionData(
group: ActionGroup.ability,
- icon: Icons.bolt,
+ icon: LucideIcons.zap,
label: 'Abilities',
),
_DeleteOptionData(
group: ActionGroup.drawing,
- icon: Icons.draw,
+ icon: LucideIcons.pencil,
label: 'Drawings',
),
_DeleteOptionData(
group: ActionGroup.text,
- icon: Icons.text_fields,
+ icon: LucideIcons.type,
label: 'Text',
),
_DeleteOptionData(
group: ActionGroup.image,
- icon: Icons.image,
+ icon: LucideIcons.image,
label: 'Images',
),
_DeleteOptionData(
group: ActionGroup.utility,
- icon: Icons.crop_square,
+ icon: LucideIcons.square,
label: 'Utilities',
),
];
diff --git a/lib/widgets/sidebar_widgets/drawing_tools.dart b/lib/widgets/sidebar_widgets/drawing_tools.dart
index 9c047bb3..9b91203c 100644
--- a/lib/widgets/sidebar_widgets/drawing_tools.dart
+++ b/lib/widgets/sidebar_widgets/drawing_tools.dart
@@ -98,7 +98,7 @@ class DrawingTools extends ConsumerWidget {
),
SelectableIconButton(
icon: const Icon(
- Icons.crop_square,
+ LucideIcons.square,
size: 20,
),
isSelected: penMode == PenMode.square,
@@ -111,7 +111,7 @@ class DrawingTools extends ConsumerWidget {
),
SelectableIconButton(
icon: const Icon(
- Icons.circle_outlined,
+ LucideIcons.circle,
size: 20,
),
isSelected: penMode == PenMode.ellipse,
diff --git a/lib/widgets/sidebar_widgets/tool_grid.dart b/lib/widgets/sidebar_widgets/tool_grid.dart
index 8355053f..32479859 100644
--- a/lib/widgets/sidebar_widgets/tool_grid.dart
+++ b/lib/widgets/sidebar_widgets/tool_grid.dart
@@ -130,7 +130,7 @@ class ToolGrid extends ConsumerWidget {
crossAxisSpacing: 5,
children: [
SelectableIconButton(
- icon: const Icon(Icons.draw),
+ icon: const Icon(LucideIcons.pencil, size: 22),
tooltip: "Draw",
shortcutLabel: shortcutLabel(IcarusShortcutAction.draw),
onPressed: () {
@@ -165,7 +165,7 @@ class ToolGrid extends ConsumerWidget {
},
icon: const Icon(
CustomIcons.eraser,
- size: 20,
+ size: 22,
),
isSelected: currentInteractionState == InteractionState.erasing,
),
@@ -184,7 +184,7 @@ class ToolGrid extends ConsumerWidget {
.update(InteractionState.textTools);
}
},
- icon: const Icon(Icons.text_fields),
+ icon: const Icon(LucideIcons.type, size: 22),
isSelected:
currentInteractionState == InteractionState.textTools,
),
@@ -244,7 +244,7 @@ class ToolGrid extends ConsumerWidget {
tagColorValue: imageResult.tagColorValue,
);
},
- icon: const Icon(Icons.image_outlined),
+ icon: const Icon(LucideIcons.image, size: 22),
),
),
SelectableIconButton(
@@ -271,7 +271,7 @@ class ToolGrid extends ConsumerWidget {
.update(InteractionState.lineUpPlacing);
}
},
- icon: const Icon(LucideIcons.bookOpen400),
+ icon: const Icon(LucideIcons.bookOpen400, size: 22),
isSelected: ref.watch(interactionStateProvider) ==
InteractionState.lineUpPlacing,
),
@@ -289,7 +289,7 @@ class ToolGrid extends ConsumerWidget {
.update(InteractionState.visionCone);
}
},
- icon: const Icon(LucideIcons.eye, size: 20),
+ icon: const Icon(LucideIcons.eye, size: 22),
isSelected:
currentInteractionState == InteractionState.visionCone,
),
@@ -307,7 +307,7 @@ class ToolGrid extends ConsumerWidget {
.update(InteractionState.customShapes);
}
},
- icon: const Icon(Icons.crop_square, size: 20),
+ icon: const Icon(LucideIcons.square, size: 22),
isSelected:
currentInteractionState == InteractionState.customShapes,
),
diff --git a/lib/widgets/strategy_quick_switcher.dart b/lib/widgets/strategy_quick_switcher.dart
index 8bd5ce04..ffba409c 100644
--- a/lib/widgets/strategy_quick_switcher.dart
+++ b/lib/widgets/strategy_quick_switcher.dart
@@ -27,8 +27,8 @@ 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 double _barHeight = 30;
+ static const EdgeInsets _displayMargin = EdgeInsets.symmetric(horizontal: 16);
final OverlayPortalController _controller = OverlayPortalController();
final LayerLink _layerLink = LayerLink();
late final TextEditingController _nameController;
@@ -350,6 +350,7 @@ class _StrategyQuickSwitcherState extends ConsumerState {
);
},
child: Container(
+ key: const ValueKey('strategy-quick-switcher-control'),
width: _barWidth,
height: _barHeight,
decoration: BoxDecoration(
@@ -482,8 +483,8 @@ class _StrategyQuickSwitcherState extends ConsumerState {
)
: Icon(
_isOpen
- ? Icons.keyboard_arrow_up
- : Icons.keyboard_arrow_down,
+ ? LucideIcons.chevronUp
+ : LucideIcons.chevronDown,
color: Colors.white,
size: 18,
),
diff --git a/lib/widgets/strategy_save_icon_button.dart b/lib/widgets/strategy_save_icon_button.dart
index 5459cd5a..63a6d3c2 100644
--- a/lib/widgets/strategy_save_icon_button.dart
+++ b/lib/widgets/strategy_save_icon_button.dart
@@ -5,81 +5,90 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:icarus/const/settings.dart';
import 'package:icarus/providers/auto_save_notifier.dart';
import 'package:icarus/providers/strategy_provider.dart';
+import 'package:icarus/widgets/editor_toolbar.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import 'package:toastification/toastification.dart';
-/// The save button that animates on auto-save pings.
+/// Saves the open strategy immediately and tells the user what landed.
+Future saveStrategyNow(BuildContext context, WidgetRef ref) async {
+ await ref
+ .read(strategyProvider.notifier)
+ .forceSaveNow(ref.read(strategyProvider).id);
+ if (!context.mounted) return;
+
+ toastification.showCustom(
+ context: context,
+ autoCloseDuration: const Duration(seconds: 3),
+ alignment: Alignment.bottomCenter,
+ builder: (context, holder) {
+ return Container(
+ margin: const EdgeInsets.all(16),
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
+ decoration: BoxDecoration(
+ color: Settings.tacticalVioletTheme.card,
+ borderRadius: BorderRadius.circular(8),
+ border: Border.all(color: Settings.tacticalVioletTheme.border),
+ ),
+ child: Text(
+ 'Save Complete',
+ style: ShadTheme.of(context)
+ .textTheme
+ .small
+ .copyWith(color: Settings.tacticalVioletTheme.foreground),
+ ),
+ );
+ },
+ );
+}
+
+/// The save button of the open strategy. Shows a spinner while an auto-save
+/// runs and a check when it lands, then rests on the save glyph.
class AutoSaveButton extends ConsumerStatefulWidget {
- const AutoSaveButton({super.key});
+ const AutoSaveButton({
+ super.key,
+ this.style = kEditorToolbarButtonStyle,
+ });
+
+ final EditorToolbarButtonStyle style;
@override
ConsumerState createState() => _AutoSaveButtonState();
}
-class _AutoSaveButtonState extends ConsumerState
- with SingleTickerProviderStateMixin {
- /// Listen to the autoSave ping counter.
- // late final AutoDisposeProviderSubscription _sub;
-
- /// Drives continuous rotation in the loading phase.
- late final AnimationController _rotationController;
-
- /// Our own internal phase.
+class _AutoSaveButtonState extends ConsumerState {
_Phase _phase = _Phase.idle;
+ Timer? _successTimer;
+ Timer? _idleTimer;
+ int _lastPing = 0;
@override
void initState() {
super.initState();
_lastPing = ref.read(autoSaveProvider);
- _rotationController = AnimationController(
- vsync: this,
- duration: const Duration(milliseconds: 800),
- )..repeat(); // we'll stop when not loading
}
@override
void dispose() {
- _rotationController.dispose();
+ _successTimer?.cancel();
+ _idleTimer?.cancel();
super.dispose();
}
void _startAutoSaveAnimation() {
if (!mounted) return;
-
+ _successTimer?.cancel();
+ _idleTimer?.cancel();
setState(() => _phase = _Phase.loading);
- _rotationController.repeat();
-
- // After 3s, show check and snackbar
- Timer(const Duration(seconds: 3), () {
+ _successTimer = Timer(const Duration(seconds: 3), () {
if (!mounted) return;
- _rotationController.stop();
setState(() => _phase = _Phase.success);
-
- // // show the snack bar here, outside build
- // ScaffoldMessenger.of(context).showSnackBar(
- // const SnackBar(
- // content: Center(
- // child: Text(
- // "Auto‐save complete",
- // style: TextStyle(color: Colors.white),
- // ),
- // ),
- // duration: Duration(seconds: 2),
- // backgroundColor: Settings.sideBarColor,
- // behavior: SnackBarBehavior.floating,
- // width: 200,
- // ),
- // );
-
- // after 1s go back to idle
- Timer(const Duration(seconds: 1), () {
+ _idleTimer = Timer(const Duration(seconds: 1), () {
if (!mounted) return;
setState(() => _phase = _Phase.idle);
});
});
}
- int _lastPing = 0;
@override
Widget build(BuildContext context) {
final ping = ref.watch(autoSaveProvider);
@@ -88,85 +97,38 @@ class _AutoSaveButtonState extends ConsumerState
_lastPing = ping;
_startAutoSaveAnimation();
}
- Widget icon;
- switch (_phase) {
- case _Phase.idle:
- _rotationController.stop();
- icon = const Icon(Icons.save);
- break;
- case _Phase.loading:
- _rotationController.stop();
- icon = const SizedBox(
- width: 24,
- height: 24,
+ final size = widget.style.iconSize;
+ final Widget icon = switch (_phase) {
+ _Phase.idle => const Icon(LucideIcons.save300, key: ValueKey('idle')),
+ _Phase.loading => SizedBox(
+ key: const ValueKey('loading'),
+ width: size - 2,
+ height: size - 2,
child: CircularProgressIndicator(
- strokeWidth: 2,
- valueColor: AlwaysStoppedAnimation(Colors.white),
+ strokeWidth: 1.8,
+ valueColor: AlwaysStoppedAnimation(
+ Settings.tacticalVioletTheme.mutedForeground,
+ ),
),
- );
- break;
-
- case _Phase.success:
- _rotationController.stop();
- icon = const Icon(Icons.check, color: Colors.greenAccent);
-
- break;
- }
-
- return ShadTooltip(
- builder: (context) => const Text("Save"),
- child: ShadIconButton.ghost(
- foregroundColor: Colors.white,
- icon: icon,
- onPressed: () async {
- // manual save path shows a SnackBar
- await ref
- .read(strategyProvider.notifier)
- .forceSaveNow(ref.read(strategyProvider).id);
- if (!context.mounted) return;
-
- toastification.showCustom(
- context: context,
- autoCloseDuration: const Duration(seconds: 3),
- alignment: Alignment.bottomCenter,
- builder: (context, holder) {
- return Container(
- margin: const EdgeInsets.all(16),
- padding:
- const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
- decoration: BoxDecoration(
- color: Settings.tacticalVioletTheme.card,
- borderRadius: BorderRadius.circular(8),
- border: Border.all(
- color: Settings.tacticalVioletTheme.border,
- ),
- ),
- child: Text(
- 'Save Complete',
- style: ShadTheme.of(context)
- .textTheme
- .small
- .copyWith(color: Colors.white),
- ),
- );
- },
- );
- // ScaffoldMessenger.of(context).showSnackBar(
- // const SnackBar(
- // content: Center(
- // child: Text(
- // "File Saved",
- // style: TextStyle(color: Colors.white),
- // ),
- // ),
- // duration: Duration(seconds: 2),
- // backgroundColor: Settings.sideBarColor,
- // behavior: SnackBarBehavior.floating,
- // width: 200,
- // ),
- // );
- },
+ ),
+ _Phase.success => const Icon(
+ LucideIcons.check300,
+ key: ValueKey('success'),
+ color: Settings.allyBGColor,
+ ),
+ };
+
+ return EditorToolbarButton(
+ key: const ValueKey('local-save-button'),
+ style: widget.style,
+ tooltip: 'Save',
+ onPressed: () => saveStrategyNow(context, ref),
+ icon: AnimatedSwitcher(
+ duration: const Duration(milliseconds: 150),
+ switchInCurve: Curves.easeOutCubic,
+ switchOutCurve: Curves.easeOutCubic,
+ child: icon,
),
);
}
diff --git a/lib/widgets/strategy_tile/strategy_tile.dart b/lib/widgets/strategy_tile/strategy_tile.dart
index 721631ee..965baeea 100644
--- a/lib/widgets/strategy_tile/strategy_tile.dart
+++ b/lib/widgets/strategy_tile/strategy_tile.dart
@@ -227,7 +227,7 @@ class _StrategyTileState extends ConsumerState {
),
child: const Padding(
padding: EdgeInsets.all(5),
- child: Icon(Icons.push_pin, size: 15),
+ child: Icon(LucideIcons.pin, size: 15),
),
),
),
@@ -248,8 +248,8 @@ class _StrategyTileState extends ConsumerState {
width: 28,
height: 28,
onPressed: _handleMenuButtonPressed,
- icon:
- const Icon(Icons.more_vert_outlined),
+ icon: const Icon(
+ LucideIcons.ellipsisVertical),
),
),
),
@@ -288,7 +288,7 @@ class _StrategyTileState extends ConsumerState {
final isPinned = pinned.containsKey(id);
return [
ShadContextMenuItem(
- leading: Icon(isPinned ? Icons.push_pin : Icons.push_pin_outlined),
+ leading: Icon(isPinned ? LucideIcons.pinOff : LucideIcons.pin),
child: Text(isPinned ? 'Unpin' : 'Pin'),
onPressed: () {
_closeMenus();
@@ -320,8 +320,10 @@ class _StrategyTileState extends ConsumerState {
},
),
ShadContextMenuItem(
- leading: const Icon(LucideIcons.trash2, color: Colors.redAccent),
- child: const Text('Delete', style: TextStyle(color: Colors.redAccent)),
+ leading: Icon(LucideIcons.trash2,
+ color: Settings.tacticalVioletTheme.destructive),
+ child: Text('Delete',
+ style: TextStyle(color: Settings.tacticalVioletTheme.destructive)),
onPressed: () {
_closeMenus();
_showDeleteDialog();
diff --git a/lib/widgets/strategy_tile/strategy_tile_sections.dart b/lib/widgets/strategy_tile/strategy_tile_sections.dart
index 55fb49f3..69693861 100644
--- a/lib/widgets/strategy_tile/strategy_tile_sections.dart
+++ b/lib/widgets/strategy_tile/strategy_tile_sections.dart
@@ -319,7 +319,7 @@ class _MoreAgentsIndicator extends StatelessWidget {
border: Border.all(color: Settings.tacticalVioletTheme.border),
),
child: const Icon(
- Icons.more_horiz,
+ LucideIcons.ellipsis,
color: Color.fromARGB(190, 210, 214, 219),
size: 18,
),
diff --git a/lib/widgets/strategy_view_skeleton.dart b/lib/widgets/strategy_view_skeleton.dart
index 16d9686c..fb17fd26 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';
@@ -41,11 +42,22 @@ class StrategyViewSkeleton extends StatelessWidget {
isAttack: isAttack,
),
),
- const Align(
+ Align(
alignment: Alignment.topLeft,
child: Padding(
- padding: EdgeInsets.all(8),
- child: _FloatingControlSkeleton(),
+ padding: const EdgeInsets.all(8),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ _MapSelectorSkeleton(
+ mapValue: resolvedMap,
+ isAttack: isAttack,
+ ),
+ const SizedBox(height: 8),
+ const _FloatingControlSkeleton(),
+ ],
+ ),
),
),
const Align(
@@ -156,23 +168,22 @@ class _SkeletonTopBar extends StatelessWidget {
@override
Widget build(BuildContext context) {
final title = strategyName?.trim();
- return Padding(
- padding: const EdgeInsets.only(left: 15, top: 15, bottom: 10, right: 15),
- child: Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ return AppWindowStrip(
+ child: Stack(
children: [
- Row(
+ const Row(
children: [
- const _SkeletonBlock(width: 40, height: 40, radius: 8),
- const SizedBox(width: 5),
- _MapSelectorSkeleton(mapValue: mapValue, isAttack: isAttack),
+ SizedBox(width: 6),
+ _SkeletonBlock(width: 28, height: 28, radius: 8),
+ Spacer(),
+ _SkeletonBlock(width: 200, height: 28, radius: 8),
+ SizedBox(width: 10),
],
),
- Padding(
- padding: const EdgeInsets.all(16),
+ Center(
child: Container(
width: 280,
- height: 40,
+ height: 30,
decoration: BoxDecoration(
color: _tone(Settings.tacticalVioletTheme.card, 0.95),
borderRadius: BorderRadius.circular(8),
@@ -195,7 +206,6 @@ class _SkeletonTopBar extends StatelessWidget {
),
),
),
- const _SkeletonBlock(width: 238, height: 40, radius: 8),
],
),
);
@@ -205,7 +215,7 @@ class _SkeletonTopBar extends StatelessWidget {
class _MapSelectorSkeleton extends StatelessWidget {
const _MapSelectorSkeleton({required this.mapValue, required this.isAttack});
- static const double _outerRadius = 10;
+ static const double _outerRadius = 12;
static const double _innerGap = 4;
static const double _innerRadius = _outerRadius - _innerGap;
@@ -221,11 +231,8 @@ class _MapSelectorSkeleton extends StatelessWidget {
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Settings.tacticalVioletTheme.card,
- borderRadius: BorderRadius.circular(10),
- border: Border.all(
- color: Settings.tacticalVioletTheme.border,
- width: 2,
- ),
+ borderRadius: BorderRadius.circular(12),
+ border: Border.all(color: Settings.tacticalVioletTheme.border),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
@@ -252,7 +259,7 @@ class _MapSelectorSkeleton extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
- isAttack ? CustomIcons.sword : Icons.shield,
+ isAttack ? CustomIcons.sword : LucideIcons.shield,
size: 20,
color: Settings.tacticalVioletTheme.mutedForeground,
),
@@ -364,16 +371,25 @@ class _FloatingControlSkeleton extends StatelessWidget {
@override
Widget build(BuildContext context) {
- return const Row(
- children: [
- _SkeletonBlock(width: 40, height: 40, radius: 8),
- SizedBox(width: 8),
- _SkeletonBlock(width: 40, height: 40, radius: 8),
- SizedBox(width: 8),
- _SkeletonBlock(width: 40, height: 40, radius: 8),
- SizedBox(width: 8),
- _SkeletonBlock(width: 40, height: 40, radius: 8),
- ],
+ // Mirrors EditorToolbar: five 32px controls in one 12px card.
+ return Container(
+ padding: const EdgeInsets.all(4),
+ decoration: BoxDecoration(
+ color: _tone(Settings.tacticalVioletTheme.card, 0.9),
+ borderRadius: BorderRadius.circular(12),
+ border: Border.all(color: _tone(Settings.highlightColor, 0.88)),
+ ),
+ child: const Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ _SkeletonBlock(width: 32, height: 32, radius: 8),
+ _SkeletonBlock(width: 32, height: 32, radius: 8),
+ _SkeletonBlock(width: 32, height: 32, radius: 8),
+ _SkeletonBlock(width: 32, height: 32, radius: 8),
+ SizedBox(width: 9),
+ _SkeletonBlock(width: 32, height: 32, radius: 8),
+ ],
+ ),
);
}
}
diff --git a/lib/widgets/vision_boundary_editor.dart b/lib/widgets/vision_boundary_editor.dart
index 7cf3cd6b..5d021c7a 100644
--- a/lib/widgets/vision_boundary_editor.dart
+++ b/lib/widgets/vision_boundary_editor.dart
@@ -203,7 +203,7 @@ class VisionBoundaryEditorHud extends ConsumerWidget {
);
}
},
- icon: const Icon(Icons.polyline_outlined, size: 20),
+ icon: const Icon(LucideIcons.spline, size: 20),
),
);
}
@@ -252,7 +252,7 @@ class _VisionBoundaryEditorPanel extends ConsumerWidget {
children: [
Row(
children: [
- const Icon(Icons.polyline_outlined, size: 18),
+ const Icon(LucideIcons.spline, size: 18),
const SizedBox(width: 8),
Expanded(
child: Column(
@@ -304,7 +304,7 @@ class _VisionBoundaryEditorPanel extends ConsumerWidget {
);
}
},
- icon: const Icon(Icons.close, size: 18),
+ icon: const Icon(LucideIcons.x, size: 18),
),
],
),
@@ -373,14 +373,14 @@ class _VisionBoundaryEditorPanel extends ConsumerWidget {
height: 32,
enabled: notifier.canUndo,
onPressed: notifier.canUndo ? notifier.undo : null,
- icon: const Icon(Icons.undo, size: 18),
+ icon: const Icon(LucideIcons.undo, size: 18),
),
ShadIconButton.ghost(
width: 32,
height: 32,
enabled: notifier.canRedo,
onPressed: notifier.canRedo ? notifier.redo : null,
- icon: const Icon(Icons.redo, size: 18),
+ icon: const Icon(LucideIcons.redo, size: 18),
),
],
),
@@ -406,19 +406,19 @@ class _VisionBoundaryEditorPanel extends ConsumerWidget {
),
const SizedBox(width: 4),
_NudgeButton(
- icon: Icons.keyboard_arrow_left,
+ icon: LucideIcons.chevronLeft,
onPressed: () => notifier.nudge(const Offset(-1, 0)),
),
_NudgeButton(
- icon: Icons.keyboard_arrow_up,
+ icon: LucideIcons.chevronUp,
onPressed: () => notifier.nudge(const Offset(0, -1)),
),
_NudgeButton(
- icon: Icons.keyboard_arrow_down,
+ icon: LucideIcons.chevronDown,
onPressed: () => notifier.nudge(const Offset(0, 1)),
),
_NudgeButton(
- icon: Icons.keyboard_arrow_right,
+ icon: LucideIcons.chevronRight,
onPressed: () => notifier.nudge(const Offset(1, 0)),
),
],
@@ -430,7 +430,7 @@ class _VisionBoundaryEditorPanel extends ConsumerWidget {
enabled: editor.isDirty,
onPressed:
editor.isDirty ? notifier.discardChanges : null,
- leading: const Icon(Icons.restart_alt, size: 16),
+ leading: const Icon(LucideIcons.rotateCcw, size: 16),
child: const Text('Discard'),
),
const SizedBox(width: 8),
@@ -445,7 +445,7 @@ class _VisionBoundaryEditorPanel extends ConsumerWidget {
Settings.tacticalVioletTheme.primary,
);
},
- leading: const Icon(Icons.copy, size: 16),
+ leading: const Icon(LucideIcons.copy, size: 16),
child: const Text('Copy JSON'),
),
const Spacer(),
@@ -491,7 +491,7 @@ class _VisionBoundaryEditorPanel extends ConsumerWidget {
strokeWidth: 2,
),
)
- : const Icon(Icons.save_outlined, size: 16),
+ : const Icon(LucideIcons.save, size: 16),
child: const Text('Save asset'),
),
],
diff --git a/lib/widgets/window_chrome.dart b/lib/widgets/window_chrome.dart
new file mode 100644
index 00000000..82e43ef7
--- /dev/null
+++ b/lib/widgets/window_chrome.dart
@@ -0,0 +1,213 @@
+import 'package:flutter/foundation.dart'
+ show TargetPlatform, defaultTargetPlatform, kIsWeb;
+import 'package:flutter/material.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. Every
+/// screen uses it, so `macos/Runner/MainFlutterWindow.swift` can center the
+/// traffic lights on it once and never move them.
+const double kWindowStripHeight = 40;
+
+/// Room reserved on the left for the native macOS traffic lights.
+const double kMacTrafficLightInset = 78;
+
+bool get _isMacOS => !kIsWeb && defaultTargetPlatform == TargetPlatform.macOS;
+bool get _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 compact Icarus lockup at the start of the library strip. On macOS it
+/// follows the traffic-light inset; on Windows and Linux it leads the strip.
+class IcarusWordmark extends StatelessWidget {
+ const IcarusWordmark({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ if (kIsWeb) {
+ return const SizedBox.shrink();
+ }
+ return Padding(
+ padding: const EdgeInsets.only(left: 10, right: 6),
+ 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,
+ // The 1px seam is the edge of the window frame: everything under it
+ // is the bench, and the floating panels on it carry their own air.
+ 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/Podfile.lock b/macos/Podfile.lock
index f3b09aa8..9c24f583 100644
--- a/macos/Podfile.lock
+++ b/macos/Podfile.lock
@@ -48,7 +48,7 @@ SPEC CHECKSUMS:
cryptography_flutter_plus: 7c6e0d0f08664499a2c5402aa7cbcca07f2a7130
custom_mouse_cursor: 5228910e366264d13615a102b1900946df2ab181
flutter_inappwebview_macos: c2d68649f9f8f1831bfcd98d73fd6256366d9d1d
- FlutterMacOS: c232990155153907050900a2e175c7773903ba4e
+ FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94
pasteboard: 278d8100149f940fb795d6b3a74f0720c890ecb7
screen_retriever_macos: 452e51764a9e1cdb74b3c541238795849f21557f
diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift
index 3cc05eb2..18da4aca 100644
--- a/macos/Runner/MainFlutterWindow.swift
+++ b/macos/Runner/MainFlutterWindow.swift
@@ -1,15 +1,85 @@
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 strip every screen draws in place of the title bar
+ /// (`kWindowStripHeight` in lib/widgets/window_chrome.dart). The traffic
+ /// lights are centered on it once and never move.
+ private let titleStripHeight: CGFloat = 40
+ private var layoutObservers: [NSObjectProtocol] = []
+
override func awakeFromNib() {
let flutterViewController = FlutterViewController()
let windowFrame = self.frame
self.contentViewController = flutterViewController
self.setFrame(windowFrame, display: true)
+ titleVisibility = .hidden
+ titlebarAppearsTransparent = true
+ styleMask.insert(.fullSizeContentView)
+
RegisterGeneratedPlugins(registry: flutterViewController)
super.awakeFromNib()
+
+ observeTitleBarLayout()
+ centerTrafficLights()
+ }
+
+ deinit {
+ for observer in layoutObservers {
+ NotificationCenter.default.removeObserver(observer)
+ }
+ }
+
+ override func layoutIfNeeded() {
+ super.layoutIfNeeded()
+ centerTrafficLights()
+ }
+
+ private func observeTitleBarLayout() {
+ let names: [Notification.Name] = [
+ NSWindow.didResizeNotification,
+ NSWindow.didExitFullScreenNotification,
+ NSWindow.didBecomeKeyNotification,
+ NSWindow.didResignKeyNotification,
+ ]
+ for name in names {
+ let observer = NotificationCenter.default.addObserver(
+ forName: name, object: self, queue: .main
+ ) { [weak self] _ in
+ self?.centerTrafficLights()
+ }
+ layoutObservers.append(observer)
+ }
+ }
+
+ /// AppKit lays the traffic lights out for its own 28pt title bar. Grow the
+ /// title bar container to the strip's height and re-center the buttons in
+ /// it, so they line up with the app's controls.
+ private func centerTrafficLights() {
+ if styleMask.contains(.fullScreen) { return }
+ guard
+ let closeButton = standardWindowButton(.closeButton),
+ let titleBarView = closeButton.superview,
+ let container = titleBarView.superview
+ else { return }
+
+ var containerFrame = container.frame
+ if containerFrame.height != titleStripHeight {
+ containerFrame.origin.y = frame.height - titleStripHeight
+ containerFrame.size.height = titleStripHeight
+ container.frame = containerFrame
+ }
+
+ let buttons: [NSWindow.ButtonType] = [.closeButton, .miniaturizeButton, .zoomButton]
+ for type in buttons {
+ guard let button = standardWindowButton(type) else { continue }
+ var origin = button.frame.origin
+ origin.y = (titleBarView.frame.height - button.frame.height) / 2
+ button.setFrameOrigin(origin)
+ }
}
}
diff --git a/pubspec.lock b/pubspec.lock
index 30bc5a52..3deb7872 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -647,10 +647,10 @@ packages:
dependency: transitive
description:
name: intl
- sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867"
+ sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
url: "https://pub.dev"
source: hosted
- version: "0.20.3"
+ version: "0.20.2"
io:
dependency: transitive
description:
@@ -743,10 +743,10 @@ packages:
dependency: transitive
description:
name: matcher
- sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
+ sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev"
source: hosted
- version: "0.12.20"
+ version: "0.12.19"
material_color_utilities:
dependency: transitive
description:
@@ -759,10 +759,10 @@ packages:
dependency: transitive
description:
name: meta
- sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9"
+ sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
url: "https://pub.dev"
source: hosted
- version: "1.19.0"
+ version: "1.18.0"
mime:
dependency: transitive
description:
@@ -1124,10 +1124,10 @@ packages:
dependency: transitive
description:
name: test_api
- sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
+ sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
url: "https://pub.dev"
source: hosted
- version: "0.7.12"
+ version: "0.7.11"
theme_extensions_builder_annotation:
dependency: transitive
description:
@@ -1284,10 +1284,10 @@ packages:
dependency: transitive
description:
name: vector_math
- sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47
+ sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.dev"
source: hosted
- version: "2.4.2"
+ version: "2.2.0"
vm_service:
dependency: transitive
description:
@@ -1393,5 +1393,5 @@ packages:
source: hosted
version: "2.1.0"
sdks:
- dart: ">=3.11.0-0 <4.0.0"
+ dart: ">=3.10.0-0 <4.0.0"
flutter: ">=3.35.0"
diff --git a/pubspec.yaml b/pubspec.yaml
index df0e8d79..a6382bfc 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -90,6 +90,7 @@ flutter:
- asset: assets/fonts/CustomIcons.ttf
assets:
- assets/
+ - assets/brand/
- assets/maps/
- assets/maps/thumbnails/
- assets/agents/
diff --git a/test/custom_shape_indicator_test.dart b/test/custom_shape_indicator_test.dart
index 26a4c360..530c115f 100644
--- a/test/custom_shape_indicator_test.dart
+++ b/test/custom_shape_indicator_test.dart
@@ -204,7 +204,7 @@ void main() {
.every((indicator) => indicator.visible),
isTrue,
);
- expect(find.byIcon(Icons.rotate_right_rounded), findsOneWidget);
+ expect(find.byIcon(LucideIcons.rotateCw), findsOneWidget);
final handle = find.byKey(
const ValueKey('custom-rectangle-rotate-top-center'),
@@ -231,7 +231,7 @@ void main() {
final activeBadgeIcon = tester.widget(
find.descendant(
of: handle,
- matching: find.byIcon(Icons.rotate_right_rounded),
+ matching: find.byIcon(LucideIcons.rotateCw),
),
);
expect(
diff --git a/test/strategy_view_skeleton_test.dart b/test/strategy_view_skeleton_test.dart
new file mode 100644
index 00000000..fd195b28
--- /dev/null
+++ b/test/strategy_view_skeleton_test.dart
@@ -0,0 +1,56 @@
+import 'package:flutter/foundation.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:icarus/const/coordinate_system.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 {
+ // DotGrid reads the play area at paint time.
+ CoordinateSystem(playAreaSize: const Size(1120, 630));
+ 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,
+ ),
+ child: StrategyViewSkeleton(
+ strategyName: 'SYNC BOUNDARY PROBE',
+ ),
+ ),
+ ),
+ );
+ await tester.pump();
+ } finally {
+ debugDefaultTargetPlatformOverride = null;
+ }
+
+ final strip = tester.getRect(find.byType(AppWindowStrip));
+ final mapThumbnail = tester.getRect(find.byType(Image).first);
+ final strategyTitle = tester.getRect(find.text('SYNC BOUNDARY PROBE'));
+ final captionButtons = tester.getRect(find.byType(WindowCaptionButtons));
+
+ // Title and caption buttons share the strip; the map card sits on the
+ // canvas below it, like the real editor.
+ expect(strip.height, kWindowStripHeight);
+ expect(strategyTitle.center.dy, closeTo(strip.center.dy, 0.5));
+ expect(captionButtons.center.dy, closeTo(strip.center.dy, 0.5));
+ expect(mapThumbnail.top, greaterThan(strip.bottom));
+ expect(tester.takeException(), isNull);
+ });
+}
diff --git a/test/vision_boundary_editor_widget_test.dart b/test/vision_boundary_editor_widget_test.dart
index 27d22c05..aa96b6c9 100644
--- a/test/vision_boundary_editor_widget_test.dart
+++ b/test/vision_boundary_editor_widget_test.dart
@@ -73,7 +73,7 @@ void main() {
VisionBoundaryEditScope.all,
);
- await tester.tap(find.byIcon(Icons.keyboard_arrow_right));
+ await tester.tap(find.byIcon(LucideIcons.chevronRight));
await tester.pumpAndSettle();
var state = container.read(visionBoundaryEditorProvider);
expect(state.draft!.outer.first, initial.outer.first + const Offset(1, 0));
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..57361cf5
--- /dev/null
+++ b/test/widgets/strategy_quick_switcher_layout_test.dart
@@ -0,0 +1,89 @@
+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/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 strip 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: AppWindowStrip(
+ child: Center(child: StrategyQuickSwitcher()),
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ await tester.pump();
+ } finally {
+ debugDefaultTargetPlatformOverride = null;
+ }
+
+ final control = tester.getRect(
+ find.byKey(const ValueKey('strategy-quick-switcher-control')),
+ );
+ final captionButtons = tester.getRect(find.byType(WindowCaptionButtons));
+ final strip = tester.getRect(find.byType(AppWindowStrip));
+
+ expect(strip.height, kWindowStripHeight);
+ // The strip's 1px bottom border sits outside its content box.
+ expect(control.center.dy, closeTo(strip.center.dy, 0.5));
+ expect(captionButtons.center.dy, closeTo(strip.center.dy, 0.5));
+ expect(control.height, 30);
+ expect(tester.takeException(), isNull);
+ });
+}
+
+class _OpenStrategyProvider extends StrategyProvider {
+ @override
+ StrategyState build() {
+ return StrategyState(
+ isSaved: true,
+ stratName: 'afeaf',
+ id: 'strategy-1',
+ storageDirectory: null,
+ );
+ }
+}