From 3bfc106876dcd24d64c34fac2f2d065b7bada9d1 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Sat, 12 Sep 2026 01:41:55 -0400 Subject: [PATCH 1/7] Fold the editor's floating controls into one toolbar and move the app to Lucide The strategy editor's floating controls become one card: save, export .ica, export video, screenshot, a hairline, then settings. Each is a 32px ghost icon button carrying an 18px glyph, so the save button can swap in a spinner or a check without the row shifting. SaveAndLoadButton is gone; EditorToolbar owns the screenshot capture, the web-only toasts and the dialogs it used to own, and AutoSaveButton now renders through the shared EditorToolbarButton. The library button in the header gets a house glyph and a tooltip that says where it goes, and the loading skeleton mirrors the new card. Material glyphs give way to Lucide across the library, editor, menus, dialogs and settings so every control draws from one icon family. Ghost buttons drop the command color so violet stays reserved for actions and selection. Delete menu items take the theme's destructive color instead of a hardcoded red. The create dialog focuses its name field, submits on Enter, and disables itself while the strategy is being created. Ported from the cloud branch, minus everything cloud: no sync button, no view-only chip, no role badges. Co-Authored-By: Claude Fable 5.1 --- lib/main.dart | 5 + lib/providers/strategy_filter_provider.dart | 7 +- lib/sidebar.dart | 2 +- lib/strategy_view.dart | 15 +- lib/widgets/better_color_picker.dart | 2 +- lib/widgets/color_picker_button.dart | 3 +- lib/widgets/custom_expansion_tile.dart | 4 +- lib/widgets/custom_search_field.dart | 5 +- lib/widgets/custom_text_field.dart | 3 + lib/widgets/delete_area.dart | 5 +- lib/widgets/demo_dialog.dart | 4 +- lib/widgets/demo_tag.dart | 2 +- lib/widgets/dialogs/lineup_panel_dialog.dart | 2 +- .../strategy/create_strategy_dialog.dart | 77 ++--- .../delete_strategy_alert_dialog.dart | 2 +- .../dialogs/strategy/line_up_media_page.dart | 14 +- .../strategy/rename_strategy_dialog.dart | 2 +- lib/widgets/dialogs/upload_image_dialog.dart | 6 +- .../ability_visibility_context_menu.dart | 6 +- .../adjacent_page_copy_menu.dart | 4 +- .../agents/agent_widget.dart | 3 +- .../placed_custom_rectangle_widget.dart | 3 +- .../utilities/view_cone_elevation_menu.dart | 7 +- lib/widgets/editor_toolbar.dart | 308 ++++++++++++++++++ lib/widgets/folder_card.dart | 20 +- lib/widgets/folder_edit_dialog.dart | 2 +- lib/widgets/folder_navigator.dart | 16 +- lib/widgets/folder_pill.dart | 17 +- lib/widgets/ica_drop_target.dart | 3 +- lib/widgets/image_drop_target.dart | 3 +- lib/widgets/line_up_media_carousel.dart | 2 +- lib/widgets/map_selector.dart | 3 +- lib/widgets/numeric_drag_input.dart | 2 +- lib/widgets/pages_bar.dart | 15 +- lib/widgets/save_and_load_button.dart | 209 ------------ lib/widgets/settings_tab.dart | 46 +-- .../sidebar_widgets/agent_dragable.dart | 4 +- .../sidebar_widgets/custom_shape_tools.dart | 6 +- .../sidebar_widgets/delete_options.dart | 12 +- .../sidebar_widgets/drawing_tools.dart | 4 +- lib/widgets/sidebar_widgets/tool_grid.dart | 14 +- lib/widgets/strategy_quick_switcher.dart | 4 +- lib/widgets/strategy_save_icon_button.dart | 198 +++++------ lib/widgets/strategy_tile/strategy_tile.dart | 14 +- .../strategy_tile/strategy_tile_sections.dart | 2 +- lib/widgets/strategy_view_skeleton.dart | 31 +- lib/widgets/vision_boundary_editor.dart | 24 +- test/custom_shape_indicator_test.dart | 4 +- test/vision_boundary_editor_widget_test.dart | 2 +- 49 files changed, 622 insertions(+), 526 deletions(-) create mode 100644 lib/widgets/editor_toolbar.dart delete mode 100644 lib/widgets/save_and_load_button.dart diff --git a/lib/main.dart b/lib/main.dart index fb96e9a8..b95a6a45 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -379,6 +379,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/sidebar.dart b/lib/sidebar.dart index f0b0e753..950dad1b 100644 --- a/lib/sidebar.dart +++ b/lib/sidebar.dart @@ -103,7 +103,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..75ca7c48 100644 --- a/lib/strategy_view.dart +++ b/lib/strategy_view.dart @@ -21,7 +21,7 @@ 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'; @@ -203,10 +203,13 @@ class _StrategyViewState extends ConsumerState children: [ Row( children: [ - ShadIconButton.ghost( - foregroundColor: Colors.white, - onPressed: _leaveToLibrary, - icon: const Icon(Icons.home), + ShadTooltip( + builder: (context) => const Text('Library'), + child: ShadIconButton.ghost( + foregroundColor: Colors.white, + onPressed: _leaveToLibrary, + icon: const Icon(LucideIcons.house, size: 20), + ), ), const SizedBox(width: 5), const MapSelector(), @@ -250,7 +253,7 @@ class _StrategyViewState extends ConsumerState alignment: Alignment.centerLeft, child: RepaintBoundary(child: InteractiveMap()), ), - Align(alignment: Alignment.topLeft, child: SaveAndLoadButton()), + Align(alignment: Alignment.topLeft, child: EditorToolbar()), Align( alignment: Alignment.bottomLeft, child: Padding( 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/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..f9d2d0f3 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}); @@ -329,8 +330,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..e0702ee5 --- /dev/null +++ b/lib/widgets/editor_toolbar.dart @@ -0,0 +1,308 @@ +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.upload), + ), + EditorToolbarButton( + style: style, + tooltip: 'Export video', + onPressed: _exportVideo, + icon: const Icon(LucideIcons.clapperboard), + ), + 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.camera), + ), + const EditorToolbarDivider(), + EditorToolbarButton( + style: style, + tooltip: 'Settings', + onPressed: () { + showShadDialog( + context: context, + builder: (context) => const SettingsTab(), + ); + }, + icon: const Icon(LucideIcons.settings), + ), + ], + ), + ), + ], + ), + ); + } + + 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: a quiet ghost icon button that comes up +/// to full foreground on hover. [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.mutedForeground; + 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_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..21277751 100644 --- a/lib/widgets/folder_navigator.dart +++ b/lib/widgets/folder_navigator.dart @@ -341,7 +341,7 @@ class _FolderNavigatorState extends ConsumerState { onPressed: handleImportIca, mainAxisAlignment: MainAxisAlignment.start, leading: const Icon( - Icons.file_download, + LucideIcons.fileDown, ), child: const Text( 'Import .ica', @@ -352,7 +352,7 @@ class _FolderNavigatorState extends ConsumerState { onPressed: handleImportBackup, mainAxisAlignment: MainAxisAlignment.start, leading: const Icon( - Icons.archive_outlined, + LucideIcons.archiveRestore, ), child: const Text('Import Backup', style: TextStyle(color: Colors.white)), @@ -361,7 +361,7 @@ class _FolderNavigatorState extends ConsumerState { onPressed: handleExportLibrary, mainAxisAlignment: MainAxisAlignment.start, leading: const Icon( - Icons.backup_outlined, + LucideIcons.archive, ), child: const Text('Export Library', style: TextStyle(color: Colors.white)), @@ -373,8 +373,8 @@ class _FolderNavigatorState extends ConsumerState { child: ShadButton.secondary( key: _importExportButtonKey, onPressed: _toggleImportExportPopover, - leading: const Icon(Icons.import_export), - trailing: const Icon(Icons.keyboard_arrow_down), + leading: const Icon(LucideIcons.arrowDownUp), + trailing: const Icon(LucideIcons.chevronDown, size: 14), child: const Text('Import / Export'), ), ), @@ -385,7 +385,7 @@ class _FolderNavigatorState extends ConsumerState { ), ShadButton( onPressed: showCreateDialog, - leading: const Icon(Icons.add), + leading: const Icon(LucideIcons.plus), child: const Text('Create Strategy'), ), ], @@ -397,12 +397,12 @@ class _FolderNavigatorState extends ConsumerState { controller: _backgroundMenuController, items: [ ShadContextMenuItem( - leading: const Icon(Icons.create_new_folder_outlined), + leading: const Icon(LucideIcons.folderPlus), onPressed: showCreateFolderDialog, child: const Text('Create Folder'), ), ShadContextMenuItem( - leading: const Icon(Icons.note_add_outlined), + leading: const Icon(LucideIcons.filePlus), onPressed: showCreateDialog, child: const Text('Create Strategy'), ), 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/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..edb8f2a9 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}); @@ -222,7 +223,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..f5d9e165 100644 --- a/lib/widgets/strategy_quick_switcher.dart +++ b/lib/widgets/strategy_quick_switcher.dart @@ -482,8 +482,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..4f43b37d 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.save, 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.check, + 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..22d1a374 100644 --- a/lib/widgets/strategy_view_skeleton.dart +++ b/lib/widgets/strategy_view_skeleton.dart @@ -252,7 +252,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 +364,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/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/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)); From 786534206e1e8367eeeebc903d11177f77dff850 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Sat, 12 Sep 2026 09:31:01 -0400 Subject: [PATCH 2/7] Rest toolbar glyphs on full foreground Co-Authored-By: Claude Fable 5.1 --- lib/widgets/editor_toolbar.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/widgets/editor_toolbar.dart b/lib/widgets/editor_toolbar.dart index e0702ee5..84abe104 100644 --- a/lib/widgets/editor_toolbar.dart +++ b/lib/widgets/editor_toolbar.dart @@ -237,8 +237,8 @@ class _EditorToolbarState extends ConsumerState { } } -/// One control in the editor toolbar: a quiet ghost icon button that comes up -/// to full foreground on hover. [icon] is any 18px glyph, so buttons can swap +/// One control in the editor toolbar: a ghost icon button in full foreground +/// (muted glyphs vanish against the card) with a tonal hover. [icon] is any 18px glyph, so buttons can swap /// in a spinner without changing size. class EditorToolbarButton extends StatelessWidget { const EditorToolbarButton({ @@ -265,7 +265,7 @@ class EditorToolbarButton extends StatelessWidget { @override Widget build(BuildContext context) { const theme = Settings.tacticalVioletTheme; - final resting = foregroundColor ?? theme.mutedForeground; + final resting = foregroundColor ?? theme.foreground; return Semantics( label: semanticsLabel ?? tooltip, button: true, From c6ebbed2c2889cd290c40cc2108dd3c1579e148d Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Sat, 12 Sep 2026 11:26:33 -0400 Subject: [PATCH 3/7] Draw editor toolbar glyphs at the 300 stroke weight Co-Authored-By: Claude Fable 5.1 --- lib/strategy_view.dart | 2 +- lib/widgets/editor_toolbar.dart | 13 +++++++------ lib/widgets/strategy_save_icon_button.dart | 4 ++-- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/lib/strategy_view.dart b/lib/strategy_view.dart index 75ca7c48..e372837d 100644 --- a/lib/strategy_view.dart +++ b/lib/strategy_view.dart @@ -208,7 +208,7 @@ class _StrategyViewState extends ConsumerState child: ShadIconButton.ghost( foregroundColor: Colors.white, onPressed: _leaveToLibrary, - icon: const Icon(LucideIcons.house, size: 20), + icon: const Icon(LucideIcons.house300, size: 20), ), ), const SizedBox(width: 5), diff --git a/lib/widgets/editor_toolbar.dart b/lib/widgets/editor_toolbar.dart index 84abe104..f60959c8 100644 --- a/lib/widgets/editor_toolbar.dart +++ b/lib/widgets/editor_toolbar.dart @@ -70,13 +70,13 @@ class _EditorToolbarState extends ConsumerState { style: style, tooltip: 'Export .ica', onPressed: _exportStrategy, - icon: const Icon(LucideIcons.upload), + icon: const Icon(LucideIcons.upload300), ), EditorToolbarButton( style: style, tooltip: 'Export video', onPressed: _exportVideo, - icon: const Icon(LucideIcons.clapperboard), + icon: const Icon(LucideIcons.clapperboard300), ), EditorToolbarButton( style: style, @@ -93,7 +93,7 @@ class _EditorToolbarState extends ConsumerState { ), ), ) - : const Icon(LucideIcons.camera), + : const Icon(LucideIcons.camera300), ), const EditorToolbarDivider(), EditorToolbarButton( @@ -105,7 +105,7 @@ class _EditorToolbarState extends ConsumerState { builder: (context) => const SettingsTab(), ); }, - icon: const Icon(LucideIcons.settings), + icon: const Icon(LucideIcons.settings300), ), ], ), @@ -237,8 +237,9 @@ class _EditorToolbarState extends ConsumerState { } } -/// One control in the editor toolbar: a ghost icon button in full foreground -/// (muted glyphs vanish against the card) with a tonal hover. [icon] is any 18px glyph, so buttons can swap +/// 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({ diff --git a/lib/widgets/strategy_save_icon_button.dart b/lib/widgets/strategy_save_icon_button.dart index 4f43b37d..63a6d3c2 100644 --- a/lib/widgets/strategy_save_icon_button.dart +++ b/lib/widgets/strategy_save_icon_button.dart @@ -100,7 +100,7 @@ class _AutoSaveButtonState extends ConsumerState { final size = widget.style.iconSize; final Widget icon = switch (_phase) { - _Phase.idle => const Icon(LucideIcons.save, key: ValueKey('idle')), + _Phase.idle => const Icon(LucideIcons.save300, key: ValueKey('idle')), _Phase.loading => SizedBox( key: const ValueKey('loading'), width: size - 2, @@ -113,7 +113,7 @@ class _AutoSaveButtonState extends ConsumerState { ), ), _Phase.success => const Icon( - LucideIcons.check, + LucideIcons.check300, key: ValueKey('success'), color: Settings.allyBGColor, ), From 78d5172a84bc529b620a705347bacfa0418e13c7 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Sat, 12 Sep 2026 13:56:48 -0400 Subject: [PATCH 4/7] Draw our own window strip and move desktop startup into one helper The native title bar goes away on desktop: macOS keeps its traffic lights centered on a 40px strip, Windows and Linux draw their own caption buttons, and every screen builds that strip from lib/widgets/window_chrome.dart. main.dart's inline window_manager and single-instance setup moves behind initializeIcarusDesktopWindow and ensureIcarusSingleInstance so web and desktop share one call site. Co-Authored-By: Claude Fable 5.1 --- DESIGN.md | 6 + assets/brand/icarus-wordmark.svg | 11 ++ lib/main.dart | 33 +--- lib/services/desktop_runtime.dart | 2 + lib/services/desktop_runtime_native.dart | 44 +++++ lib/services/desktop_runtime_stub.dart | 8 + lib/widgets/window_chrome.dart | 212 +++++++++++++++++++++++ macos/Runner/MainFlutterWindow.swift | 70 ++++++++ pubspec.yaml | 1 + 9 files changed, 362 insertions(+), 25 deletions(-) create mode 100644 assets/brand/icarus-wordmark.svg create mode 100644 lib/services/desktop_runtime.dart create mode 100644 lib/services/desktop_runtime_native.dart create mode 100644 lib/services/desktop_runtime_stub.dart create mode 100644 lib/widgets/window_chrome.dart 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 b95a6a45..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 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/widgets/window_chrome.dart b/lib/widgets/window_chrome.dart new file mode 100644 index 00000000..bd5b8cd1 --- /dev/null +++ b/lib/widgets/window_chrome.dart @@ -0,0 +1,212 @@ +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 Windows library strip. +class WindowsIcarusWordmark extends StatelessWidget { + const WindowsIcarusWordmark({super.key}); + + @override + Widget build(BuildContext context) { + if (!_isWindows) { + return const SizedBox.shrink(); + } + return Padding( + padding: const EdgeInsets.only(left: 10, right: 4), + child: SvgPicture.asset( + 'assets/brand/icarus-wordmark.svg', + height: 14, + semanticsLabel: 'Icarus', + ), + ); + } +} + +/// Blank space where the macOS traffic lights sit. Collapses in full screen, +/// where macOS hides them, and on every other platform. +class MacTrafficLightInset extends StatefulWidget { + const MacTrafficLightInset({super.key}); + + @override + State createState() => _MacTrafficLightInsetState(); +} + +class _MacTrafficLightInsetState extends State + with WindowListener { + bool _fullScreen = false; + + @override + void initState() { + super.initState(); + if (!_isMacOS) return; + windowManager.addListener(this); + windowManager.isFullScreen().then((value) { + if (mounted && value != _fullScreen) { + setState(() => _fullScreen = value); + } + }); + } + + @override + void dispose() { + if (_isMacOS) { + windowManager.removeListener(this); + } + super.dispose(); + } + + @override + void onWindowEnterFullScreen() => setState(() => _fullScreen = true); + + @override + void onWindowLeaveFullScreen() => setState(() => _fullScreen = false); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: _isMacOS && !_fullScreen ? kMacTrafficLightInset : 0); + } +} + +/// Minimize, maximize, and close for platforms whose native buttons went +/// away with the title bar. Empty on macOS and web. +class WindowCaptionButtons extends StatefulWidget { + const WindowCaptionButtons({super.key}); + + @override + State createState() => _WindowCaptionButtonsState(); +} + +class _WindowCaptionButtonsState extends State + with WindowListener { + bool _maximized = false; + + @override + void initState() { + super.initState(); + if (!_drawsCaptionButtons) return; + windowManager.addListener(this); + windowManager.isMaximized().then((value) { + if (mounted && value != _maximized) { + setState(() => _maximized = value); + } + }); + } + + @override + void dispose() { + if (_drawsCaptionButtons) { + windowManager.removeListener(this); + } + super.dispose(); + } + + @override + void onWindowMaximize() => setState(() => _maximized = true); + + @override + void onWindowUnmaximize() => setState(() => _maximized = false); + + @override + Widget build(BuildContext context) { + if (!_drawsCaptionButtons) { + return const SizedBox.shrink(); + } + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + WindowCaptionButton.minimize( + brightness: Brightness.dark, + onPressed: windowManager.minimize, + ), + if (_maximized) + WindowCaptionButton.unmaximize( + brightness: Brightness.dark, + onPressed: windowManager.unmaximize, + ) + else + WindowCaptionButton.maximize( + brightness: Brightness.dark, + onPressed: windowManager.maximize, + ), + // Goes through window_manager so the editor's unsaved-changes guard + // (setPreventClose) still runs. + WindowCaptionButton.close( + brightness: Brightness.dark, + onPressed: windowManager.close, + ), + ], + ); + } +} + +/// The frame that stands in for the native title bar. The screen places a +/// [WindowDragArea] only in its empty space so controls receive taps without +/// waiting for the title bar's double-click gesture. +class AppWindowStrip extends StatelessWidget { + const AppWindowStrip({super.key, required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + return Container( + key: const ValueKey('app-window-strip'), + height: kWindowStripHeight, + // 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/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.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/ From 5fe15f2fa122138864ea31e8d2ff299a56e8d137 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Sat, 12 Sep 2026 13:58:55 -0400 Subject: [PATCH 5/7] Put the library's controls in the window strip The Material AppBar goes away: tabs sit at the left of the 40px strip and search, sort, and New sit at its right, so the library has one row of chrome instead of a title bar plus a filter row. Shared and Community hold their places dimmed until they have somewhere to go. Inside a folder the path moves out of the title and onto its own card in the content area. Co-Authored-By: Claude Fable 5.1 --- lib/widgets/current_path_bar.dart | 98 ------- lib/widgets/folder_content.dart | 77 +----- lib/widgets/folder_navigator.dart | 131 ++------- lib/widgets/library_breadcrumb.dart | 133 +++++++++ lib/widgets/library_title_strip.dart | 394 +++++++++++++++++++++++++++ pubspec.lock | 22 +- 6 files changed, 568 insertions(+), 287 deletions(-) delete mode 100644 lib/widgets/current_path_bar.dart create mode 100644 lib/widgets/library_breadcrumb.dart create mode 100644 lib/widgets/library_title_strip.dart 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/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_navigator.dart b/lib/widgets/folder_navigator.dart index 21277751..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( - LucideIcons.fileDown, - ), - child: const Text( - 'Import .ica', - style: TextStyle(color: Colors.white), - ), - ), - ShadButton.ghost( - onPressed: handleImportBackup, - mainAxisAlignment: MainAxisAlignment.start, - leading: const Icon( - LucideIcons.archiveRestore, - ), - child: const Text('Import Backup', - style: TextStyle(color: Colors.white)), - ), - ShadButton.ghost( - onPressed: handleExportLibrary, - mainAxisAlignment: MainAxisAlignment.start, - leading: const Icon( - LucideIcons.archive, - ), - child: const Text('Export Library', - style: TextStyle(color: Colors.white)), - ), - ], - ), - ); - }, - child: ShadButton.secondary( - key: _importExportButtonKey, - onPressed: _toggleImportExportPopover, - leading: const Icon(LucideIcons.arrowDownUp), - trailing: const Icon(LucideIcons.chevronDown, size: 14), - 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(LucideIcons.plus), - child: const Text('Create Strategy'), - ), - ], - ) - ], - // ... your existing actions - ), - body: ShadContextMenuRegion( - controller: _backgroundMenuController, - items: [ - ShadContextMenuItem( - leading: const Icon(LucideIcons.folderPlus), - onPressed: showCreateFolderDialog, - child: const Text('Create Folder'), - ), - ShadContextMenuItem( - leading: const Icon(LucideIcons.filePlus), - onPressed: showCreateDialog, - child: const Text('Create Strategy'), + ], + child: FolderContent(folder: currentFolder), + ), ), ], - child: FolderContent(folder: currentFolder), ), ), if (_desktopUpdaterController != null) 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..802c367c --- /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 WindowsIcarusWordmark(), + 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/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" From ca728075da53608233e0a312695b5e2f4965c686 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Sat, 12 Sep 2026 14:01:11 -0400 Subject: [PATCH 6/7] Hang the editor off the same window strip as the library The editor's top row becomes the 40px strip: Library on the left, the quick switcher centered on it at 30px, Discord on the right. The map card leaves the bar and joins the toolbar in a column at the canvas top-left, so the canvas runs up to the seam and every floating panel keeps its own 8px of air. Two layout tests pin the strip's height and center line. Co-Authored-By: Claude Fable 5.1 --- lib/sidebar.dart | 6 +- lib/strategy_view.dart | 110 ++++++++++++------ lib/widgets/delete_area.dart | 4 +- lib/widgets/map_selector.dart | 7 +- lib/widgets/strategy_quick_switcher.dart | 5 +- lib/widgets/strategy_view_skeleton.dart | 49 ++++---- pubspec.lock | 22 ++-- test/strategy_view_skeleton_test.dart | 56 +++++++++ .../strategy_quick_switcher_layout_test.dart | 89 ++++++++++++++ 9 files changed, 273 insertions(+), 75 deletions(-) create mode 100644 test/strategy_view_skeleton_test.dart create mode 100644 test/widgets/strategy_quick_switcher_layout_test.dart diff --git a/lib/sidebar.dart b/lib/sidebar.dart index 950dad1b..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( diff --git a/lib/strategy_view.dart b/lib/strategy_view.dart index e372837d..30d4d244 100644 --- a/lib/strategy_view.dart +++ b/lib/strategy_view.dart @@ -25,6 +25,7 @@ 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,59 +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: [ + const SizedBox(width: 6), ShadTooltip( builder: (context) => const Text('Library'), child: ShadIconButton.ghost( - foregroundColor: Colors.white, + width: 28, + height: 28, + foregroundColor: + Settings.tacticalVioletTheme.mutedForeground, + hoverForegroundColor: + Settings.tacticalVioletTheme.foreground, onPressed: _leaveToLibrary, - icon: const Icon(LucideIcons.house300, size: 20), + 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, @@ -253,7 +241,21 @@ class _StrategyViewState extends ConsumerState alignment: Alignment.centerLeft, child: RepaintBoundary(child: InteractiveMap()), ), - Align(alignment: Alignment.topLeft, child: EditorToolbar()), + 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( @@ -292,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/delete_area.dart b/lib/widgets/delete_area.dart index f9d2d0f3..f1e3cc3a 100644 --- a/lib/widgets/delete_area.dart +++ b/lib/widgets/delete_area.dart @@ -226,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( diff --git a/lib/widgets/map_selector.dart b/lib/widgets/map_selector.dart index edb8f2a9..fe179a82 100644 --- a/lib/widgets/map_selector.dart +++ b/lib/widgets/map_selector.dart @@ -18,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; @@ -77,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, diff --git a/lib/widgets/strategy_quick_switcher.dart b/lib/widgets/strategy_quick_switcher.dart index f5d9e165..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( diff --git a/lib/widgets/strategy_view_skeleton.dart b/lib/widgets/strategy_view_skeleton.dart index 22d1a374..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, diff --git a/pubspec.lock b/pubspec.lock index 3deb7872..30bc5a52 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -647,10 +647,10 @@ packages: dependency: transitive description: name: intl - sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" url: "https://pub.dev" source: hosted - version: "0.20.2" + version: "0.20.3" io: dependency: transitive description: @@ -743,10 +743,10 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.20" material_color_utilities: dependency: transitive description: @@ -759,10 +759,10 @@ packages: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.19.0" mime: dependency: transitive description: @@ -1124,10 +1124,10 @@ packages: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.12" theme_extensions_builder_annotation: dependency: transitive description: @@ -1284,10 +1284,10 @@ packages: dependency: transitive description: name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47 url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.4.2" vm_service: dependency: transitive description: @@ -1393,5 +1393,5 @@ packages: source: hosted version: "2.1.0" sdks: - dart: ">=3.10.0-0 <4.0.0" + dart: ">=3.11.0-0 <4.0.0" flutter: ">=3.35.0" 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/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, + ); + } +} From 59fe4cce50e698ec9b8c6711ea2e7657c9f95345 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Sat, 12 Sep 2026 14:39:49 -0400 Subject: [PATCH 7/7] Show the Icarus wordmark in the strip on every desktop platform Co-Authored-By: Claude Fable 5.1 --- lib/widgets/library_title_strip.dart | 2 +- lib/widgets/window_chrome.dart | 11 ++++++----- macos/Podfile.lock | 2 +- pubspec.lock | 22 +++++++++++----------- 4 files changed, 19 insertions(+), 18 deletions(-) diff --git a/lib/widgets/library_title_strip.dart b/lib/widgets/library_title_strip.dart index 802c367c..7be4003f 100644 --- a/lib/widgets/library_title_strip.dart +++ b/lib/widgets/library_title_strip.dart @@ -59,7 +59,7 @@ class _LibraryTitleStripState extends ConsumerState { return AppWindowStrip( child: Row( children: [ - const WindowsIcarusWordmark(), + const IcarusWordmark(), const SizedBox(width: 6), _TabButton( key: const ValueKey('library-tab-library'), diff --git a/lib/widgets/window_chrome.dart b/lib/widgets/window_chrome.dart index bd5b8cd1..82e43ef7 100644 --- a/lib/widgets/window_chrome.dart +++ b/lib/widgets/window_chrome.dart @@ -40,17 +40,18 @@ class WindowDragArea extends StatelessWidget { } } -/// The compact Icarus lockup at the start of the Windows library strip. -class WindowsIcarusWordmark extends StatelessWidget { - const WindowsIcarusWordmark({super.key}); +/// 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 (!_isWindows) { + if (kIsWeb) { return const SizedBox.shrink(); } return Padding( - padding: const EdgeInsets.only(left: 10, right: 4), + padding: const EdgeInsets.only(left: 10, right: 6), child: SvgPicture.asset( 'assets/brand/icarus-wordmark.svg', height: 14, 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/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"