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, + ); + } +}