From 068e7f9061635300ea49c62a92dd9730f9be6f04 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Tue, 15 Sep 2026 10:02:19 -0400 Subject: [PATCH 01/11] Make the side toggle apply to every page, Shift for this page only Placements are stored attack-canonical, so a page's side is only the direction the map is drawn from. A plain click on the side toggle now flips every page; Shift+click flips this page only, which is how a strategy becomes mixed on purpose. The toggle shows a dot when pages disagree and its tooltip explains both paths. Adds two editable shortcuts: Switch Side (F) and Switch Side (This Page) (Shift+F). Schema, export, and import are unchanged. Co-Authored-By: Claude Fable 5.1 --- lib/const/shortcut_info.dart | 27 +++++ lib/providers/strategy_provider.dart | 25 +++++ lib/widgets/global_shortcuts.dart | 19 ++++ lib/widgets/map_selector.dart | 124 +++++++++++++++------- test/strategy_switch_side_test.dart | 150 +++++++++++++++++++++++++++ 5 files changed, 308 insertions(+), 37 deletions(-) create mode 100644 test/strategy_switch_side_test.dart diff --git a/lib/const/shortcut_info.dart b/lib/const/shortcut_info.dart index 2f992e06..200705ed 100644 --- a/lib/const/shortcut_info.dart +++ b/lib/const/shortcut_info.dart @@ -14,6 +14,8 @@ enum IcarusShortcutAction { backwardPage, addPage, addLineup, + switchSide, + switchSideThisPage, openDeleteMenu, saveStrategy, pasteImage, @@ -276,6 +278,23 @@ class ShortcutInfo { intent: ToggleLineupIntent(), searchAliases: ['lineup'], ), + IcarusShortcutDefinition( + action: IcarusShortcutAction.switchSide, + title: 'Switch Side', + defaultBinding: IcarusKeyBinding(trigger: LogicalKeyboardKey.keyF), + intent: SwitchSideIntent(), + searchAliases: ['attack', 'defense', 'defend', 'flip', 'side'], + ), + IcarusShortcutDefinition( + action: IcarusShortcutAction.switchSideThisPage, + title: 'Switch Side (This Page)', + defaultBinding: IcarusKeyBinding( + trigger: LogicalKeyboardKey.keyF, + shift: true, + ), + intent: SwitchSideThisPageIntent(), + searchAliases: ['attack', 'defense', 'defend', 'flip', 'side', 'page'], + ), IcarusShortcutDefinition( action: IcarusShortcutAction.openDeleteMenu, title: 'Open Delete Menu', @@ -525,3 +544,11 @@ class ToggleLineupIntent extends Intent { class OpenInAppDebugIntent extends Intent { const OpenInAppDebugIntent(); } + +class SwitchSideIntent extends Intent { + const SwitchSideIntent(); +} + +class SwitchSideThisPageIntent extends Intent { + const SwitchSideThisPageIntent(); +} diff --git a/lib/providers/strategy_provider.dart b/lib/providers/strategy_provider.dart index 6e2433e0..1898fffc 100644 --- a/lib/providers/strategy_provider.dart +++ b/lib/providers/strategy_provider.dart @@ -4026,6 +4026,31 @@ class StrategyProvider extends Notifier { setUnsaved(); } + /// Flips the side the map is drawn from. Placements are stored + /// attack-canonical, so the only thing that changes is each page's side. + /// With [allPages] every page takes the active page's new side; otherwise + /// only the active page changes and the strategy may become mixed. + Future switchSide({required bool allPages}) async { + final isAttack = !ref.read(mapProvider).isAttack; + ref.read(mapProvider.notifier).setAttack(isAttack); + setUnsaved(); + if (!allPages || state.stratName == null) return; + + await _syncCurrentPageToHive(); + + final box = Hive.box(HiveBoxNames.strategiesBox); + final strat = box.get(state.id); + if (strat == null || strat.pages.isEmpty) return; + + final updated = strat.copyWith( + pages: [ + for (final page in strat.pages) page.copyWith(isAttack: isAttack), + ], + lastEdited: DateTime.now(), + ); + await box.put(updated.id, updated); + } + Future applyNeutralTeamColorsToAllPages(bool value) async { if (state.stratName == null) return; diff --git a/lib/widgets/global_shortcuts.dart b/lib/widgets/global_shortcuts.dart index 0935f021..8659e9c5 100644 --- a/lib/widgets/global_shortcuts.dart +++ b/lib/widgets/global_shortcuts.dart @@ -100,6 +100,25 @@ class _GlobalShortcutsState extends ConsumerState return null; }, ), + SwitchSideIntent: CallbackAction( + onInvoke: (intent) async { + _dismissDeleteMenu(); + await ref + .read(strategyProvider.notifier) + .switchSide(allPages: true); + return null; + }, + ), + SwitchSideThisPageIntent: + CallbackAction( + onInvoke: (intent) async { + _dismissDeleteMenu(); + await ref + .read(strategyProvider.notifier) + .switchSide(allPages: false); + return null; + }, + ), AddPageIntent: CallbackAction( onInvoke: (intent) async { _dismissDeleteMenu(); diff --git a/lib/widgets/map_selector.dart b/lib/widgets/map_selector.dart index 237e19ca..bfdb8415 100644 --- a/lib/widgets/map_selector.dart +++ b/lib/widgets/map_selector.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:hive_ce_flutter/hive_flutter.dart'; import 'package:icarus/const/custom_icons.dart'; +import 'package:icarus/const/hive_boxes.dart'; import 'package:icarus/const/maps.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/providers/map_provider.dart'; @@ -223,44 +226,9 @@ class _MapSelectorState extends ConsumerState { ), ), const SizedBox(width: _innerGap), - SizedBox( + const SizedBox( width: _sideToggleWidth, - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: () { - ref.read(mapProvider.notifier).switchSide(); - ref.read(strategyProvider.notifier).setUnsaved(); - }, - mouseCursor: SystemMouseCursors.click, - borderRadius: BorderRadius.circular(_innerRadius), - hoverColor: Colors.white.withValues(alpha: 0.08), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - (ref.watch(mapProvider).isAttack) - ? CustomIcons.sword - : LucideIcons.shield, - size: 20, - color: (ref.watch(mapProvider).isAttack) - ? Colors.redAccent - : Colors.blueAccent, - ), - Text( - (ref.watch(mapProvider).isAttack) - ? "Attack" - : "Defense", - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, - color: Colors.white, - ), - ), - ], - ), - ), - ), + child: _SideToggle(borderRadius: _innerRadius), ), ], ), @@ -269,3 +237,85 @@ class _MapSelectorState extends ConsumerState { ); } } + +/// Flips the side the map is drawn from. A click applies to every page; +/// Shift+click applies to this page only, which is how a strategy becomes +/// mixed. When it is mixed, a dot warns that a plain click will unify it. +class _SideToggle extends ConsumerWidget { + const _SideToggle({required this.borderRadius}); + + final double borderRadius; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isAttack = ref.watch(mapProvider.select((state) => state.isAttack)); + final strategyId = ref.watch(strategyProvider.select((state) => state.id)); + final box = Hive.box(HiveBoxNames.strategiesBox); + + return ValueListenableBuilder( + valueListenable: box.listenable(keys: [strategyId]), + builder: (context, Box b, _) { + final pages = b.get(strategyId)?.pages ?? const []; + final mixed = + pages.any((page) => page.isAttack != pages.first.isAttack); + final nextSide = isAttack ? 'Defense' : 'Attack'; + final tooltip = mixed + ? 'Pages are on mixed sides\n' + 'Click: all pages to $nextSide\n' + 'Shift+click: this page only' + : 'Switch side on all pages\nShift+click: this page only'; + + return ShadTooltip( + builder: (context) => Text(tooltip), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + ref.read(strategyProvider.notifier).switchSide( + allPages: !HardwareKeyboard.instance.isShiftPressed, + ); + }, + mouseCursor: SystemMouseCursors.click, + borderRadius: BorderRadius.circular(borderRadius), + hoverColor: Colors.white.withValues(alpha: 0.08), + child: Stack( + children: [ + Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + isAttack ? CustomIcons.sword : LucideIcons.shield, + size: 20, + color: isAttack ? Colors.redAccent : Colors.blueAccent, + ), + Text( + isAttack ? "Attack" : "Defense", + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Colors.white, + ), + ), + ], + ), + if (mixed) + const Positioned( + top: 6, + right: 6, + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.orangeAccent, + shape: BoxShape.circle, + ), + child: SizedBox(width: 6, height: 6), + ), + ), + ], + ), + ), + ), + ); + }, + ); + } +} diff --git a/test/strategy_switch_side_test.dart b/test/strategy_switch_side_test.dart new file mode 100644 index 00000000..daff68bc --- /dev/null +++ b/test/strategy_switch_side_test.dart @@ -0,0 +1,150 @@ +import 'dart:io'; +import 'dart:ui'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive_ce/hive.dart'; +import 'package:icarus/const/agents.dart'; +import 'package:icarus/const/coordinate_system.dart'; +import 'package:icarus/const/hive_boxes.dart'; +import 'package:icarus/const/maps.dart'; +import 'package:icarus/const/placed_classes.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:icarus/hive/hive_registration.dart'; +import 'package:icarus/providers/folder_provider.dart'; +import 'package:icarus/providers/map_provider.dart'; +import 'package:icarus/providers/strategy_page.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/providers/strategy_settings_provider.dart'; +import 'package:icarus/providers/user_preferences_provider.dart'; + +// The provider's initial state carries this id, so a strategy stored under it +// can be activated without going through the file-backed load path. +const _strategyId = 'testID'; + +StrategyPage _page( + String id, + int index, { + required bool isAttack, + bool withAgent = true, +}) { + return StrategyPage( + id: id, + sortIndex: index, + name: 'Page ${index + 1}', + isAutoNamed: true, + drawingData: const [], + agentData: [ + if (withAgent) + PlacedAgent( + id: '$id-agent', + type: AgentType.sova, + position: Offset(10.0 * (index + 1), 20), + ), + ], + abilityData: const [], + textData: const [], + imageData: const [], + utilityData: const [], + isAttack: isAttack, + settings: StrategySettings(), + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory tempDir; + late ProviderContainer container; + + setUpAll(() async { + CoordinateSystem(playAreaSize: const Size(1920, 1080)); + tempDir = await Directory.systemTemp.createTemp('icarus-switch-side'); + Hive.init(tempDir.path); + if (!Hive.isAdapterRegistered(20)) { + registerIcarusAdapters(Hive); + } + await Hive.openBox(HiveBoxNames.strategiesBox); + await Hive.openBox(HiveBoxNames.foldersBox); + await Hive.openBox(HiveBoxNames.mapThemeProfilesBox); + await Hive.openBox(HiveBoxNames.appPreferencesBox); + await Hive.openBox(HiveBoxNames.favoriteAgentsBox); + }); + + setUp(() async { + final box = Hive.box(HiveBoxNames.strategiesBox); + await box.put( + _strategyId, + StrategyData( + id: _strategyId, + name: 'Mixed', + mapData: MapValue.ascent, + versionNumber: Settings.versionNumber, + lastEdited: DateTime.utc(2026, 1, 1), + folderID: null, + pages: [ + // Activating a page first flushes the (empty) editor state into the + // page that was active before, which defaults to the first page. + _page('p1', 0, isAttack: true, withAgent: false), + _page('p2', 1, isAttack: false), + _page('p3', 2, isAttack: true), + ], + ), + ); + + container = ProviderContainer(); + final notifier = container.read(strategyProvider.notifier); + await notifier.renameStrategy(_strategyId, 'Mixed'); + await notifier.setActivePage('p2'); + }); + + tearDown(() async { + container.dispose(); + await Hive.box(HiveBoxNames.strategiesBox).clear(); + }); + + tearDownAll(() async { + await Hive.close(); + await tempDir.delete(recursive: true); + }); + + List storedPages() { + final pages = [ + ...Hive.box(HiveBoxNames.strategiesBox) + .get(_strategyId)! + .pages, + ]..sort((a, b) => a.sortIndex.compareTo(b.sortIndex)); + return pages; + } + + test('switching side on all pages unifies a mixed strategy', () async { + expect(container.read(mapProvider).isAttack, isFalse); + + await container + .read(strategyProvider.notifier) + .switchSide(allPages: true); + + expect(container.read(mapProvider).isAttack, isTrue); + final pages = storedPages(); + expect(pages.map((p) => p.isAttack), everyElement(isTrue)); + expect( + pages.skip(1).map((p) => p.agentData.single.position).toList(), + [const Offset(20, 20), const Offset(30, 20)], + reason: 'side is a view choice; canonical placements must not move', + ); + }); + + test('switching side on this page leaves the other pages alone', () async { + await container + .read(strategyProvider.notifier) + .switchSide(allPages: false); + + expect(container.read(mapProvider).isAttack, isTrue); + expect(container.read(strategyProvider).isSaved, isFalse); + expect( + storedPages().map((p) => p.isAttack).toList(), + [true, false, true], + reason: 'the active page is written on the next save, not here', + ); + }); +} From d775d1cabdace9a0263c32dce511654e5f0d0b98 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Tue, 15 Sep 2026 19:12:48 -0700 Subject: [PATCH 02/11] Create a strategy by picking its map The create dialog is now a grid of map cards: one click creates the strategy, named after the map with the first free number, and drops into the editor. Out-of-rotation maps fold below. Hovering a card lifts it and quiets the rest. The empty library offers the same dialog through a New Strategy button. Co-Authored-By: Claude Fable 5.1 --- lib/const/maps.dart | 6 + lib/providers/strategy_provider.dart | 30 +- .../strategy/create_strategy_dialog.dart | 261 +++++++++++++++--- lib/widgets/folder_content.dart | 61 +++- lib/widgets/folder_navigator.dart | 5 +- lib/widgets/map_selector.dart | 32 +-- lib/widgets/map_tile.dart | 15 +- test/auto_strategy_name_test.dart | 28 ++ 8 files changed, 360 insertions(+), 78 deletions(-) create mode 100644 test/auto_strategy_name_test.dart diff --git a/lib/const/maps.dart b/lib/const/maps.dart index be087d99..15d7e98d 100644 --- a/lib/const/maps.dart +++ b/lib/const/maps.dart @@ -48,6 +48,12 @@ class Maps { MapValue.pearl, ]; + /// The map's name as users read it: "Ascent", "Icebox". + static String displayName(MapValue map) { + final raw = mapNames[map]!; + return raw[0].toUpperCase() + raw.substring(1); + } + static Map mapNames = { MapValue.ascent: 'ascent', MapValue.breeze: 'breeze', diff --git a/lib/providers/strategy_provider.dart b/lib/providers/strategy_provider.dart index 1898fffc..86b9983c 100644 --- a/lib/providers/strategy_provider.dart +++ b/lib/providers/strategy_provider.dart @@ -3353,7 +3353,15 @@ class StrategyProvider extends Notifier { ); } - Future createNewStrategy(String name) async { + /// Creates an empty strategy on [map] and returns its id. Without [name] + /// it is auto-named after the map ("Haven", then "Haven 2", ...). + Future createNewStrategy({ + required MapValue map, + String? name, + }) async { + final box = Hive.box(HiveBoxNames.strategiesBox); + final strategyName = name ?? + autoStrategyName(map, box.values.map((strategy) => strategy.name)); final newID = const Uuid().v4(); final pageID = const Uuid().v4(); final defaultThemeProfileId = @@ -3366,10 +3374,10 @@ class StrategyProvider extends Notifier { appPreferences.defaultNeutralTeamColorsForNewStrategies, ); final newStrategy = StrategyData( - mapData: MapValue.ascent, + mapData: map, versionNumber: Settings.versionNumber, id: newID, - name: name, + name: strategyName, pages: [ StrategyPage( id: pageID, @@ -3394,8 +3402,7 @@ class StrategyProvider extends Notifier { themeProfileId: defaultThemeProfileId, ); - await Hive.box(HiveBoxNames.strategiesBox) - .put(newStrategy.id, newStrategy); + await box.put(newStrategy.id, newStrategy); unawaited(AnalyticsService.instance.capture('strategy_created')); @@ -4093,3 +4100,16 @@ class StrategyProvider extends Notifier { } } } + +/// The name a new strategy gets when the user doesn't type one: the map's +/// name, with the first free number appended if that name is already taken. +@visibleForTesting +String autoStrategyName(MapValue map, Iterable existingNames) { + final base = Maps.displayName(map); + final taken = existingNames.map((name) => name.trim().toLowerCase()).toSet(); + if (!taken.contains(base.toLowerCase())) return base; + for (var n = 2;; n++) { + final candidate = '$base $n'; + if (!taken.contains(candidate.toLowerCase())) return candidate; + } +} diff --git a/lib/widgets/dialogs/strategy/create_strategy_dialog.dart b/lib/widgets/dialogs/strategy/create_strategy_dialog.dart index d320baef..00fa87d9 100644 --- a/lib/widgets/dialogs/strategy/create_strategy_dialog.dart +++ b/lib/widgets/dialogs/strategy/create_strategy_dialog.dart @@ -1,44 +1,42 @@ +import 'dart:ui' show lerpDouble; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/const/maps.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/providers/strategy_provider.dart'; -import 'package:icarus/widgets/custom_text_field.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; +const double _tileWidth = 220; +const double _tileHeight = 110; +const double _tileGap = 8; +const int _columns = 4; +const double _gridWidth = _columns * _tileWidth + (_columns - 1) * _tileGap; + +/// Creating a strategy is picking its map. One tap on a tile creates the +/// strategy, auto-named after the map, and pops with its id. class CreateStrategyDialog extends ConsumerStatefulWidget { const CreateStrategyDialog({super.key}); @override ConsumerState createState() => - _NameStrategyDialogState(); + _CreateStrategyDialogState(); } -class _NameStrategyDialogState extends ConsumerState { - final TextEditingController _textController = TextEditingController(); +class _CreateStrategyDialogState extends ConsumerState { bool _isSubmitting = false; + bool _showOutOfRotation = false; + MapValue? _hoveredMap; - @override - void dispose() { - _textController.dispose(); - super.dispose(); - } + static List _sorted(List maps) => maps.toList() + ..sort((a, b) => Maps.displayName(a).compareTo(Maps.displayName(b))); - Future _submit() async { + Future _create(MapValue map) 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); + final strategyID = + await ref.read(strategyProvider.notifier).createNewStrategy(map: map); if (!mounted) return; Navigator.of(context).pop(strategyID); } catch (_) { @@ -50,25 +48,218 @@ class _NameStrategyDialogState extends ConsumerState { } } + Widget _grid(List maps) { + return Wrap( + alignment: WrapAlignment.center, + spacing: _tileGap, + runSpacing: _tileGap, + children: [ + for (final map in maps) + _MapCard( + key: ValueKey('create-strategy-map-${Maps.mapNames[map]}'), + map: map, + hovered: _hoveredMap == map, + quiet: _hoveredMap != null && _hoveredMap != map, + onHover: (over) => setState( + () => _hoveredMap = + over ? map : (_hoveredMap == map ? null : _hoveredMap), + ), + onTap: () => _create(map), + ), + ], + ); + } + @override Widget build(BuildContext context) { + final theme = ShadTheme.of(context); return ShadDialog( - title: const Text('Create Strategy'), - actions: [ - ShadButton( - onPressed: _isSubmitting ? null : _submit, - child: Text(_isSubmitting ? 'Creating…' : 'Create'), + title: const Text('Pick a map'), + // Shad caps dialogs at 512; the grid plus padding needs more. + constraints: const BoxConstraints(maxWidth: _gridWidth + 48), + child: Material( + color: Colors.transparent, + child: IgnorePointer( + ignoring: _isSubmitting, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 150), + opacity: _isSubmitting ? 0.6 : 1, + child: SizedBox( + width: _gridWidth, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 8), + _grid(_sorted(Maps.availableMaps)), + const SizedBox(height: 12), + ShadButton.ghost( + key: const ValueKey('create-strategy-out-of-rotation'), + size: ShadButtonSize.sm, + padding: EdgeInsets.zero, + onPressed: () => setState( + () => _showOutOfRotation = !_showOutOfRotation, + ), + trailing: AnimatedRotation( + duration: const Duration(milliseconds: 120), + turns: _showOutOfRotation ? 0.5 : 0, + child: const Icon(LucideIcons.chevronDown, size: 14), + ), + child: Text( + 'Out of rotation', + style: theme.textTheme.small + .copyWith(color: theme.colorScheme.mutedForeground), + ), + ), + AnimatedSize( + duration: const Duration(milliseconds: 120), + curve: Curves.easeOutCubic, + alignment: Alignment.topCenter, + child: _showOutOfRotation + ? Padding( + padding: const EdgeInsets.only(top: 8), + child: _grid(_sorted(Maps.outofplayMaps)), + ) + : const SizedBox(width: _gridWidth), + ), + ], + ), + ), + ), ), - ], - child: SizedBox( - width: 300, - child: CustomTextField( - hintText: 'Enter strategy name', - controller: _textController, - autofocus: true, - onSubmitted: (_) => _submit(), + ), + ); + } +} + +/// A map to pick: its art with the name centred over it. Hovering one map +/// lifts it, rings it, and quiets every other card so the choice stands alone. +class _MapCard extends StatelessWidget { + const _MapCard({ + super.key, + required this.map, + required this.hovered, + required this.quiet, + required this.onHover, + required this.onTap, + }); + + final MapValue map; + final bool hovered; + final bool quiet; + final ValueChanged onHover; + final VoidCallback onTap; + + static const _duration = Duration(milliseconds: 90); + + @override + Widget build(BuildContext context) { + // 0 is quiet, 1 is rest, 2 is hovered; one tween carries all three. + final target = hovered ? 2.0 : (quiet ? 0.0 : 1.0); + return MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => onHover(true), + onExit: (_) => onHover(false), + child: GestureDetector( + onTap: onTap, + child: SizedBox( + width: _tileWidth, + height: _tileHeight, + child: Stack( + fit: StackFit.expand, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: TweenAnimationBuilder( + duration: _duration, + curve: Curves.easeOut, + tween: Tween(end: target), + builder: (context, t, child) { + final lift = (t - 1).clamp(0.0, 1.0); + final hush = (1 - t).clamp(0.0, 1.0); + return ColorFiltered( + colorFilter: _tint( + saturation: lerpDouble(1, .35, hush)!, + brightness: + lerpDouble(lerpDouble(.85, 1, lift)!, .55, hush)!, + ), + child: Transform.scale( + scale: lerpDouble(1, 1.04, lift)!, + child: child, + ), + ); + }, + child: Image.asset( + 'assets/maps/thumbnails/${Maps.mapNames[map]}_thumbnail.webp', + fit: BoxFit.cover, + ), + ), + ), + AnimatedOpacity( + duration: _duration, + opacity: quiet ? .5 : 1, + child: Center( + child: Text( + Maps.displayName(map).toUpperCase(), + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + color: Colors.white, + shadows: [ + Shadow( + color: Colors.black, + blurRadius: 2, + offset: Offset(0, 2), + ), + ], + ), + ), + ), + ), + AnimatedContainer( + duration: _duration, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all( + width: hovered ? 2 : 1, + color: hovered + ? Settings.accentInk + : Colors.white.withValues(alpha: .10), + ), + ), + ), + ], + ), ), ), ); } } + +/// A colour matrix that scales saturation and brightness, 1 being untouched. +ColorFilter _tint({required double saturation, required double brightness}) { + const r = 0.2126, g = 0.7152, b = 0.0722; + final s = saturation, v = brightness; + return ColorFilter.matrix([ + (r + (1 - r) * s) * v, + (g - g * s) * v, + (b - b * s) * v, + 0, + 0, + (r - r * s) * v, + (g + (1 - g) * s) * v, + (b - b * s) * v, + 0, + 0, + (r - r * s) * v, + (g - g * s) * v, + (b + (1 - b) * s) * v, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]); +} diff --git a/lib/widgets/folder_content.dart b/lib/widgets/folder_content.dart index a7c0b46f..65a75746 100644 --- a/lib/widgets/folder_content.dart +++ b/lib/widgets/folder_content.dart @@ -15,6 +15,7 @@ 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({ @@ -122,9 +123,12 @@ Set _folderAndDescendantIds(Folder root, Iterable allFolders) { } class FolderContent extends ConsumerWidget { - FolderContent({super.key, this.folder}); + FolderContent({super.key, this.folder, required this.onCreateStrategy}); final Folder? folder; // null for root + + /// Opens the create-strategy dialog; offered from the empty state. + final VoidCallback onCreateStrategy; final strategiesListenable = Provider>>((ref) { return Hive.box(HiveBoxNames.strategiesBox).listenable(); @@ -253,16 +257,10 @@ class FolderContent extends ConsumerWidget { // Check if both folders and strategies are empty if (folders.isEmpty && strategies.isEmpty) { - return const IcaDropTarget( - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text('No strategies available'), - Text( - "Create a new strategy or drop strategies, folders, or .zip archives") - ], - ), + return IcaDropTarget( + child: _EmptyState( + searching: search.isNotEmpty, + onCreateStrategy: onCreateStrategy, ), ); } @@ -398,3 +396,44 @@ class FolderContent extends ConsumerWidget { ); } } + +/// What the grid shows when nothing is in it: an invitation to create the +/// first strategy, or a plain "no matches" when a search filtered it out. +class _EmptyState extends StatelessWidget { + const _EmptyState({ + required this.searching, + required this.onCreateStrategy, + }); + + final bool searching; + final VoidCallback onCreateStrategy; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + final title = searching ? 'No matches' : 'No strategies yet'; + final hint = searching + ? 'Try a different search' + : 'Create one, or drop strategies, folders, or .zip archives here'; + + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(title, style: theme.textTheme.p), + const SizedBox(height: 4), + Text(hint, style: theme.textTheme.muted), + if (!searching) ...[ + const SizedBox(height: 16), + ShadButton( + key: const ValueKey('library-empty-new-strategy'), + onPressed: onCreateStrategy, + leading: const Icon(LucideIcons.plus, size: 16), + child: const Text('New Strategy'), + ), + ], + ], + ), + ); + } +} diff --git a/lib/widgets/folder_navigator.dart b/lib/widgets/folder_navigator.dart index 33a5fb99..f6a6e1fe 100644 --- a/lib/widgets/folder_navigator.dart +++ b/lib/widgets/folder_navigator.dart @@ -323,7 +323,10 @@ class _FolderNavigatorState extends ConsumerState { child: const Text('Create Strategy'), ), ], - child: FolderContent(folder: currentFolder), + child: FolderContent( + folder: currentFolder, + onCreateStrategy: showCreateDialog, + ), ), ), ], diff --git a/lib/widgets/map_selector.dart b/lib/widgets/map_selector.dart index bfdb8415..3b7b1881 100644 --- a/lib/widgets/map_selector.dart +++ b/lib/widgets/map_selector.dart @@ -27,8 +27,7 @@ class _MapSelectorState extends ConsumerState { static const double _sideToggleWidth = 66; // Sized from the contents so the gap on the right of the side toggle equals // the gap on the left of the map tile. - static const double _cardWidth = - 2 * _borderWidth + + static const double _cardWidth = 2 * _borderWidth + 2 * _innerGap + MapTile.width + _innerGap + @@ -67,20 +66,19 @@ class _MapSelectorState extends ConsumerState { @override Widget build(BuildContext context) { final MapValue currentMap = ref.watch(mapProvider).currentMap; - final List availableMaps = - Maps.mapNames.keys - .where((mapValue) => Maps.availableMaps.contains(mapValue)) - .toList() - ..sort( - (a, b) => Maps.mapNames[a]!.toLowerCase().compareTo( + final List availableMaps = Maps.mapNames.keys + .where((mapValue) => Maps.availableMaps.contains(mapValue)) + .toList() + ..sort( + (a, b) => Maps.mapNames[a]!.toLowerCase().compareTo( Maps.mapNames[b]!.toLowerCase(), ), - ); + ); final List outOfRotationMaps = Maps.outofplayMaps.toList() ..sort( (a, b) => Maps.mapNames[a]!.toLowerCase().compareTo( - Maps.mapNames[b]!.toLowerCase(), - ), + Maps.mapNames[b]!.toLowerCase(), + ), ); return CompositedTransformTarget( @@ -135,8 +133,7 @@ class _MapSelectorState extends ConsumerState { Expanded( child: ListView.separated( padding: const EdgeInsets.all(_innerGap), - itemCount: - availableMaps.length + + itemCount: availableMaps.length + outOfRotationMaps.length + 1, separatorBuilder: (_, __) => @@ -183,10 +180,8 @@ class _MapSelectorState extends ConsumerState { ); } - final mapValue = - outOfRotationMaps[index - - availableMaps.length - - 1]; + final mapValue = outOfRotationMaps[ + index - availableMaps.length - 1]; final mapName = Maps.mapNames[mapValue]!; return MapTile( name: mapName, @@ -279,6 +274,9 @@ class _SideToggle extends ConsumerWidget { borderRadius: BorderRadius.circular(borderRadius), hoverColor: Colors.white.withValues(alpha: 0.08), child: Stack( + // Fill the toggle so the column centres in the box, not in + // the width of its own label. + fit: StackFit.expand, children: [ Column( mainAxisAlignment: MainAxisAlignment.center, diff --git a/lib/widgets/map_tile.dart b/lib/widgets/map_tile.dart index 61d2851a..9c4366cd 100644 --- a/lib/widgets/map_tile.dart +++ b/lib/widgets/map_tile.dart @@ -32,12 +32,10 @@ class _MapTileState extends ConsumerState { @override Widget build(BuildContext context) { return MouseRegion( - onEnter: widget.isPreview - ? null - : (_) => setState(() => _isHovered = true), - onExit: widget.isPreview - ? null - : (_) => setState(() => _isHovered = false), + onEnter: + widget.isPreview ? null : (_) => setState(() => _isHovered = true), + onExit: + widget.isPreview ? null : (_) => setState(() => _isHovered = false), child: ClipRRect( borderRadius: BorderRadius.all(Radius.circular(widget.borderRadius)), child: InkWell( @@ -75,9 +73,8 @@ class _MapTileState extends ConsumerState { style: TextStyle( fontWeight: FontWeight.bold, fontSize: 16, - color: widget.isActive - ? Settings.tacticalVioletTheme.primary - : Colors.white, + color: + widget.isActive ? Settings.accentInk : Colors.white, shadows: const [ Shadow( color: Colors.black, diff --git a/test/auto_strategy_name_test.dart b/test/auto_strategy_name_test.dart new file mode 100644 index 00000000..cb3d1c31 --- /dev/null +++ b/test/auto_strategy_name_test.dart @@ -0,0 +1,28 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/const/maps.dart'; +import 'package:icarus/providers/strategy_provider.dart'; + +void main() { + group('autoStrategyName', () { + test('uses the map name when it is free', () { + expect(autoStrategyName(MapValue.haven, const []), 'Haven'); + expect(autoStrategyName(MapValue.haven, const ['Ascent']), 'Haven'); + }); + + test('appends the first free number when the name is taken', () { + expect(autoStrategyName(MapValue.haven, const ['Haven']), 'Haven 2'); + expect( + autoStrategyName(MapValue.haven, const ['Haven', 'Haven 2']), + 'Haven 3', + ); + expect( + autoStrategyName(MapValue.haven, const ['Haven', 'Haven 3']), + 'Haven 2', + ); + }); + + test('ignores case and surrounding whitespace in existing names', () { + expect(autoStrategyName(MapValue.haven, const [' haven ']), 'Haven 2'); + }); + }); +} From 2dc06e9f1775c69c45654ce09511fd7658938e93 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Tue, 15 Sep 2026 19:12:48 -0700 Subject: [PATCH 03/11] Lift dialogs onto the card surface and split the violet into fill and ink Dialogs step up from the canvas with the card colour and a 16px radius. A new accentInk token, violet-500, carries every thin violet stroke, ring, and glyph; primary stays violet-700 for fills. Inputs draw a lighter border, outline buttons use foreground text, and the favorites toggle, delete-all, and add-page buttons take the raised treatment. Co-Authored-By: Claude Fable 5.1 --- lib/const/settings.dart | 25 ++++++++++++++-- lib/main.dart | 29 +++++++++++++++++++ lib/sidebar.dart | 4 +-- lib/widgets/current_line_up_painter.dart | 2 +- lib/widgets/custom_expansion_tile.dart | 6 ++-- lib/widgets/desktop_update_dialog.dart | 4 +-- .../agents/agent_widget.dart | 4 +-- .../shared/framed_ability_icon_shell.dart | 6 ++-- .../placed_custom_rectangle_widget.dart | 2 +- lib/widgets/folder_edit_dialog.dart | 2 +- lib/widgets/library_title_strip.dart | 2 +- lib/widgets/numeric_drag_input.dart | 5 ++-- lib/widgets/pages_bar.dart | 5 ++-- lib/widgets/selectable_icon_button.dart | 18 +++++++----- lib/widgets/settings_tab.dart | 19 ++++++------ .../sidebar_widgets/color_buttons.dart | 7 +++-- lib/widgets/strategy_quick_switcher.dart | 3 +- .../strategy_tile/strategy_tile_sections.dart | 2 +- lib/widgets/vision_boundary_editor.dart | 2 +- 19 files changed, 99 insertions(+), 48 deletions(-) diff --git a/lib/const/settings.dart b/lib/const/settings.dart index 8d7ccc39..6efbe742 100644 --- a/lib/const/settings.dart +++ b/lib/const/settings.dart @@ -188,6 +188,12 @@ class Settings { // ), ), )); + + /// Violet for lines, glyphs, text, and strokes on dark surfaces: two + /// steps lighter than [tacticalVioletTheme.primary], which is the fill + /// under white text and too dark to read as a thin mark. + static const Color accentInk = Color(0xff8b5cf6); // violet-500 + static const ShadColorScheme tacticalVioletTheme = ShadColorScheme( // --- THE GRAYS (UNCHANGED) --- // These are the "Zinc" cool grays you liked. @@ -204,15 +210,16 @@ class Settings { accent: Color(0xff27272a), accentForeground: Color(0xfffafafa), border: Color(0xff27272a), - input: Color(0xff27272a), + // Zinc-700: field edges sit one step above the panel border so a field + // on a card still reads as a field. + input: Color(0xff3f3f46), // --- THE NEW PURPLE (UPDATED) --- // Violet-700: Higher contrast, deeper, premium look. primary: Color(0xff7c3aed), primaryForeground: Color(0xfff9fafb), // Pure white text pops perfectly here - // Updated ring to match the new primary - ring: Color(0xff7c3aed), + ring: accentInk, // Selection can stay a bit darker (Violet-800) or match primary selection: Color(0xff4c1d95), @@ -294,6 +301,18 @@ class Settings { static InsetShadowDecoration raisedPrimary(double radius) => raised(tacticalVioletTheme.primary, radius); + /// Dialogs are panels: one surface step above the canvas at the dialog + /// radius, one step above the floating panels, so they read as a sheet + /// from the same family rather than a black box on black. + static final ShadDialogTheme dialogTheme = ShadDialogTheme( + backgroundColor: tacticalVioletTheme.card, + radius: const BorderRadius.all(Radius.circular(16)), + ); + + /// The destructive fill, raised the same way as the primary one. + static final LinearGradient raisedDestructiveFill = + raisedGradient(tacticalVioletTheme.destructive); + /// The primary fill alone, for the Shad theme and animated fills. static final LinearGradient raisedPrimaryFill = raisedGradient(tacticalVioletTheme.primary); diff --git a/lib/main.dart b/lib/main.dart index 7e919518..dc15d62c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -362,11 +362,29 @@ class _MyAppState extends ConsumerState { brightness: Brightness.dark, colorScheme: Settings.tacticalVioletTheme, breadcrumbTheme: const ShadBreadcrumbTheme(separatorSize: 18), + // Dialogs are panels: one surface step above the canvas, so they + // read as a sheet rather than a black box on a black screen. + // Fields keep the panel's surface and take the lighter input + // edge, so they read on a card without becoming a dark well. + inputTheme: ShadInputTheme( + decoration: ShadDecoration( + border: ShadBorder.all( + width: 1, + color: Settings.tacticalVioletTheme.input, + radius: const BorderRadius.all(Radius.circular(6)), + ), + ), + ), + primaryDialogTheme: Settings.dialogTheme, + alertDialogTheme: Settings.dialogTheme, // 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, ), + outlineButtonTheme: ShadButtonTheme( + foregroundColor: Settings.tacticalVioletTheme.foreground, + ), // Primary commands are raised like the selected tab: a lighter top // of the fill, a bright 1px edge inside the top, a 1px shadow // beneath. A rounded Border must be one color, so the theme paints @@ -381,6 +399,17 @@ class _MyAppState extends ConsumerState { ), ), ), + // Destructive commands are raised the same way, in red. + destructiveButtonTheme: ShadButtonTheme( + decoration: ShadDecoration( + gradient: Settings.raisedDestructiveFill, + shadows: const [Settings.raisedDropShadow], + border: const ShadBorder( + radius: BorderRadius.all(Radius.circular(6)), + top: ShadBorderSide(color: Settings.raisedTopLight, width: 1), + ), + ), + ), ), home: const MyHomePage(), routes: { diff --git a/lib/sidebar.dart b/lib/sidebar.dart index d20ff37a..cfdee55f 100644 --- a/lib/sidebar.dart +++ b/lib/sidebar.dart @@ -107,8 +107,8 @@ class _SideBarUIState extends ConsumerState { .toggleFavoritesOnly(); }, icon: Icon( - LucideIcons.star600, - size: 24, + LucideIcons.star, + size: 20, color: filterState.favoritesOnly ? Colors.white : Settings.tacticalVioletTheme diff --git a/lib/widgets/current_line_up_painter.dart b/lib/widgets/current_line_up_painter.dart index 2e9ad26c..af170edc 100644 --- a/lib/widgets/current_line_up_painter.dart +++ b/lib/widgets/current_line_up_painter.dart @@ -95,7 +95,7 @@ class CurrentLineUpPainter extends ConsumerWidget { child: CustomPaint( painter: _CurrentLinePainter( strokeWidth: coordinateSystem.scale(Settings.brushSize), - color: Settings.tacticalVioletTheme.primary, + color: Settings.accentInk, originAnchor: originAnchor, landingAnchor: landingAnchor, dragHover: ref.watch(lineUpDragHoverProvider), diff --git a/lib/widgets/custom_expansion_tile.dart b/lib/widgets/custom_expansion_tile.dart index b52e59d7..b28ecd9a 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:icarus/const/settings.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; class CustomExpansionTile extends StatefulWidget { @@ -134,18 +135,17 @@ class _CustomExpansionTileState extends State @override Widget build(BuildContext context) { final theme = Theme.of(context); - final colorScheme = theme.colorScheme; final Color backgroundColor = _isExpanded ? widget.backgroundColor ?? Colors.transparent : widget.collapsedBackgroundColor ?? Colors.transparent; final Color? titleColor = _isExpanded - ? widget.textColor ?? colorScheme.primary + ? widget.textColor ?? Settings.accentInk : widget.collapsedTextColor ?? theme.textTheme.titleMedium?.color; final Color iconColor = _isExpanded - ? widget.iconColor ?? colorScheme.primary + ? widget.iconColor ?? Settings.accentInk : widget.collapsedIconColor ?? theme.unselectedWidgetColor; return Material( diff --git a/lib/widgets/desktop_update_dialog.dart b/lib/widgets/desktop_update_dialog.dart index a35614f6..765e8981 100644 --- a/lib/widgets/desktop_update_dialog.dart +++ b/lib/widgets/desktop_update_dialog.dart @@ -268,7 +268,7 @@ class _PatchNotes extends StatelessWidget { case 'warning': return theme.colorScheme.destructive; case 'feature': - return theme.colorScheme.primary; + return Settings.accentInk; default: return theme.colorScheme.mutedForeground; } @@ -383,7 +383,7 @@ class _DownloadFillButton extends StatelessWidget { duration: const Duration(milliseconds: 120), curve: Curves.easeOut, width: width * fill, - color: theme.colorScheme.primary, + color: Settings.accentInk, ), ), Center( diff --git a/lib/widgets/draggable_widgets/agents/agent_widget.dart b/lib/widgets/draggable_widgets/agents/agent_widget.dart index d0a66a8a..574187fd 100644 --- a/lib/widgets/draggable_widgets/agents/agent_widget.dart +++ b/lib/widgets/draggable_widgets/agents/agent_widget.dart @@ -146,7 +146,7 @@ class AgentWidget extends ConsumerWidget { bgColor = Color.lerp(bgColor, deadBgColor, deadProgress) ?? bgColor; if (isLineUpHovered) { - bgColor = Colors.deepPurple; + bgColor = Settings.tacticalVioletTheme.primary; } // Determine outline color @@ -162,7 +162,7 @@ class AgentWidget extends ConsumerWidget { outlineColor; if (isLineUpHovered) { - outlineColor = Colors.deepPurpleAccent; + outlineColor = Settings.accentInk; } Widget agentDisplay = agentImage; diff --git a/lib/widgets/draggable_widgets/shared/framed_ability_icon_shell.dart b/lib/widgets/draggable_widgets/shared/framed_ability_icon_shell.dart index f5428b22..abe7ca7d 100644 --- a/lib/widgets/draggable_widgets/shared/framed_ability_icon_shell.dart +++ b/lib/widgets/draggable_widgets/shared/framed_ability_icon_shell.dart @@ -36,10 +36,12 @@ class FramedAbilityIconShell extends ConsumerWidget { padding: EdgeInsets.all(coordinateSystem.scale(3)), decoration: BoxDecoration( borderRadius: const BorderRadius.all(Radius.circular(3)), - color: isLineUpHovered ? Colors.deepPurple : Settings.abilityBGColor, + color: isLineUpHovered + ? Settings.tacticalVioletTheme.primary + : Settings.abilityBGColor, border: Border.all( color: isLineUpHovered - ? Colors.deepPurpleAccent + ? Settings.accentInk : useNeutralTeamColors ? Settings.neutralTeamShade(outlineColor) : outlineColor, 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 67e7f2e1..bd809ddc 100644 --- a/lib/widgets/draggable_widgets/utilities/placed_custom_rectangle_widget.dart +++ b/lib/widgets/draggable_widgets/utilities/placed_custom_rectangle_widget.dart @@ -844,7 +844,7 @@ class _RotationBadge extends StatelessWidget { child: Icon( LucideIcons.rotateCw, size: size, - color: isActive ? Settings.tacticalVioletTheme.primary : Colors.white, + color: isActive ? Settings.accentInk : Colors.white, shadows: const [ Shadow(color: Colors.black87, blurRadius: 4), Shadow(color: Colors.black87, blurRadius: 1), diff --git a/lib/widgets/folder_edit_dialog.dart b/lib/widgets/folder_edit_dialog.dart index 0b9ae7a4..a2d3c84f 100644 --- a/lib/widgets/folder_edit_dialog.dart +++ b/lib/widgets/folder_edit_dialog.dart @@ -318,7 +318,7 @@ class _FolderEditDialogState extends ConsumerState { selectedIcon: FolderIconView( iconId: iconId, size: iconSize, - color: Settings.tacticalVioletTheme.primary, + color: Settings.accentInk, )); }, ), diff --git a/lib/widgets/library_title_strip.dart b/lib/widgets/library_title_strip.dart index 31d01367..26ba9d77 100644 --- a/lib/widgets/library_title_strip.dart +++ b/lib/widgets/library_title_strip.dart @@ -349,7 +349,7 @@ class _MenuItem extends StatelessWidget { icon, size: 16, color: icon == LucideIcons.check - ? Settings.tacticalVioletTheme.primary + ? Settings.accentInk : Settings.tacticalVioletTheme.mutedForeground, ), ), diff --git a/lib/widgets/numeric_drag_input.dart b/lib/widgets/numeric_drag_input.dart index 1ec47759..283fbaee 100644 --- a/lib/widgets/numeric_drag_input.dart +++ b/lib/widgets/numeric_drag_input.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:icarus/const/settings.dart'; import 'package:flutter/services.dart'; import 'package:icarus/widgets/text_editing_shortcut_scope.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; @@ -233,7 +234,7 @@ class _NumericDragInputState extends State { final isFocused = _focusNode.hasFocus; final labelStyle = (widget.labelTextStyle ?? shadTheme.textTheme.lead).copyWith( - color: _isDragging ? colorScheme.primary : colorScheme.mutedForeground, + color: _isDragging ? Settings.accentInk : colorScheme.mutedForeground, ); final valueStyle = (widget.valueTextStyle ?? shadTheme.textTheme.lead) .copyWith(color: colorScheme.foreground); @@ -324,7 +325,7 @@ class _NumericDragInputState extends State { LucideIcons.gripVertical, size: widget.dragIconSize, color: _isDragging - ? colorScheme.primary + ? Settings.accentInk : colorScheme.mutedForeground, ), ), diff --git a/lib/widgets/pages_bar.dart b/lib/widgets/pages_bar.dart index 078ce29d..d0808862 100644 --- a/lib/widgets/pages_bar.dart +++ b/lib/widgets/pages_bar.dart @@ -706,9 +706,7 @@ class _ResizeHandleStatefulState extends State<_ResizeHandleStateful> { @override Widget build(BuildContext context) { final isHighlighted = widget.isActive || _isHovered; - final handleColor = isHighlighted - ? Settings.tacticalVioletTheme.primary - : Colors.transparent; + final handleColor = isHighlighted ? Settings.accentInk : Colors.transparent; final isHorizontalResize = widget.axis == Axis.horizontal; return MouseRegion( @@ -974,6 +972,7 @@ class _SquareIconButton extends StatelessWidget { Widget build(BuildContext context) { final button = ShadTooltip( builder: (context) => Text(tooltip), + // The primary button theme already raises this; the color is the base. child: ShadIconButton( backgroundColor: color, hoverBackgroundColor: color, diff --git a/lib/widgets/selectable_icon_button.dart b/lib/widgets/selectable_icon_button.dart index e320018b..18027eff 100644 --- a/lib/widgets/selectable_icon_button.dart +++ b/lib/widgets/selectable_icon_button.dart @@ -27,21 +27,23 @@ class SelectableIconButton extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final hasShortcutLabel = shortcutLabel != null && shortcutLabel!.isNotEmpty; - // A checked tool is a raised command surface. A caller-supplied color - // (the delete tools' red) stays a flat fill. - final raised = isSelected && hoverBackgroundColor == null; - final flatColor = - isSelected ? hoverBackgroundColor ?? Colors.transparent : null; + // A checked tool is a raised command surface: violet by default, or the + // caller's color (the favorites amber, the delete tools' red). A + // transparent color means the caller wants no fill at all. + final raisedColor = isSelected + ? hoverBackgroundColor ?? Settings.tacticalVioletTheme.primary + : null; + final raised = raisedColor != null && raisedColor.a > 0; Widget button = ShadIconButton.secondary( padding: EdgeInsets.zero, icon: icon, - backgroundColor: flatColor, - hoverBackgroundColor: flatColor, + backgroundColor: isSelected ? Colors.transparent : null, + hoverBackgroundColor: isSelected ? Colors.transparent : null, onPressed: onPressed, ); if (raised) { button = DecoratedBox( - decoration: Settings.raisedPrimary(_radius), + decoration: Settings.raised(raisedColor, _radius), child: button, ); } diff --git a/lib/widgets/settings_tab.dart b/lib/widgets/settings_tab.dart index 56628b29..ed3d0143 100644 --- a/lib/widgets/settings_tab.dart +++ b/lib/widgets/settings_tab.dart @@ -202,7 +202,7 @@ class _StrategySettingsSections extends ConsumerWidget { min: Settings.agentSizeMin, max: Settings.agentSizeMax, divisions: 15, - accentColor: Settings.tacticalVioletTheme.primary, + accentColor: Settings.accentInk, onChanged: (value) { ref .read(strategySettingsProvider.notifier) @@ -230,7 +230,7 @@ class _StrategySettingsSections extends ConsumerWidget { min: Settings.abilitySizeMin, max: Settings.abilitySizeMax, divisions: 15, - accentColor: Settings.tacticalVioletTheme.primary, + accentColor: Settings.accentInk, onChanged: (value) { ref .read(strategySettingsProvider.notifier) @@ -314,7 +314,7 @@ class _GlobalSettingsSections extends ConsumerWidget { min: Settings.agentSizeMin, max: Settings.agentSizeMax, divisions: 15, - accentColor: Settings.tacticalVioletTheme.primary, + accentColor: Settings.accentInk, onChanged: (value) { ref .read(appPreferencesProvider.notifier) @@ -331,7 +331,7 @@ class _GlobalSettingsSections extends ConsumerWidget { min: Settings.abilitySizeMin, max: Settings.abilitySizeMax, divisions: 15, - accentColor: Settings.tacticalVioletTheme.primary, + accentColor: Settings.accentInk, onChanged: (value) { ref .read(appPreferencesProvider.notifier) @@ -623,7 +623,7 @@ class _ShortcutSearchField extends StatelessWidget { color: Settings.tacticalVioletTheme.foreground, fontSize: 13, ), - cursorColor: Settings.tacticalVioletTheme.primary, + cursorColor: Settings.accentInk, decoration: InputDecoration( isDense: true, hintText: "Search actions or keys...", @@ -648,8 +648,8 @@ class _ShortcutSearchField extends StatelessWidget { ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(8), - borderSide: BorderSide( - color: Settings.tacticalVioletTheme.primary, + borderSide: const BorderSide( + color: Settings.accentInk, width: 1.4, ), ), @@ -933,8 +933,7 @@ class _ShortcutCaptureFieldState extends State<_ShortcutCaptureField> border: Border.all( color: hasDuplicate ? Settings.tacticalVioletTheme.destructive - : Settings.tacticalVioletTheme.primary - .withValues(alpha: 0.65), + : Settings.accentInk.withValues(alpha: 0.65), width: hasDuplicate ? 1.4 : 1, ), ), @@ -945,7 +944,7 @@ class _ShortcutCaptureFieldState extends State<_ShortcutCaptureField> size: 17, color: hasDuplicate ? Settings.tacticalVioletTheme.destructive - : Settings.tacticalVioletTheme.primary, + : Settings.accentInk, ), const SizedBox(width: 9), Expanded( diff --git a/lib/widgets/sidebar_widgets/color_buttons.dart b/lib/widgets/sidebar_widgets/color_buttons.dart index 9e7df2ac..a0fd396c 100644 --- a/lib/widgets/sidebar_widgets/color_buttons.dart +++ b/lib/widgets/sidebar_widgets/color_buttons.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:icarus/const/settings.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; class ColorButtons extends ConsumerStatefulWidget { @@ -22,7 +23,7 @@ class ColorButtons extends ConsumerStatefulWidget { class _ColorButtonsState extends ConsumerState { final _hoverColor = Colors.white; // final _selectColor = const Color(0xFF2282FF); - final _selectColor = Colors.deepPurpleAccent; + final _selectColor = Settings.accentInk; Color _currentColor = Colors.transparent; @@ -88,7 +89,7 @@ class _ColorButtonsState extends ConsumerState { // required this.color, // required this.isSelected, // required this.onTap, - + // }); // final bool isSelected; // final Color color; @@ -111,7 +112,7 @@ class _ColorButtonsState extends ConsumerState { // child: InkWell( // onTap: onTap, // onHover: (value) { - + // }, // child: Center( // child: Container( diff --git a/lib/widgets/strategy_quick_switcher.dart b/lib/widgets/strategy_quick_switcher.dart index 049a5907..de0b3cdb 100644 --- a/lib/widgets/strategy_quick_switcher.dart +++ b/lib/widgets/strategy_quick_switcher.dart @@ -416,8 +416,7 @@ class _StrategyQuickSwitcherState extends ConsumerState { enabled: !_isRenaming, textAlign: TextAlign.center, textInputAction: TextInputAction.done, - cursorColor: - Settings.tacticalVioletTheme.primary, + cursorColor: Settings.accentInk, style: ShadTheme.of(context) .textTheme .small diff --git a/lib/widgets/strategy_tile/strategy_tile_sections.dart b/lib/widgets/strategy_tile/strategy_tile_sections.dart index 69693861..13f8deeb 100644 --- a/lib/widgets/strategy_tile/strategy_tile_sections.dart +++ b/lib/widgets/strategy_tile/strategy_tile_sections.dart @@ -256,7 +256,7 @@ class StrategyTileDragPreview extends StatelessWidget { decoration: BoxDecoration( color: Settings.tacticalVioletTheme.card, borderRadius: BorderRadius.circular(16), - border: Border.all(color: Colors.deepPurpleAccent, width: 2), + border: Border.all(color: Settings.accentInk, width: 2), ), padding: const EdgeInsets.all(4), child: Row( diff --git a/lib/widgets/vision_boundary_editor.dart b/lib/widgets/vision_boundary_editor.dart index 5d021c7a..b8c4d7db 100644 --- a/lib/widgets/vision_boundary_editor.dart +++ b/lib/widgets/vision_boundary_editor.dart @@ -567,7 +567,7 @@ class VisionBoundaryEditorPainter extends CustomPainter { ..style = PaintingStyle.stroke ..strokeWidth = 1.25 / zoom; final selectedPaint = Paint() - ..color = Settings.tacticalVioletTheme.primary + ..color = Settings.accentInk ..style = PaintingStyle.stroke ..strokeWidth = 2.5 / zoom; final pointPaint = Paint() From 4c5fdd2ed3f48eb81a78981792cdf813874a075a Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Tue, 15 Sep 2026 19:12:48 -0700 Subject: [PATCH 04/11] Give the lineup panel and upload dialog their space back The lineup panel sizes to the window and lets the media fill its pane. The upload drop zone recesses into the background. The editor toolbar drops its doubled padding so it lines up with the map card. Co-Authored-By: Claude Fable 5.1 --- lib/widgets/dialogs/lineup_panel_dialog.dart | 37 +++--- lib/widgets/dialogs/upload_image_dialog.dart | 6 +- lib/widgets/editor_toolbar.dart | 117 +++++++++---------- 3 files changed, 81 insertions(+), 79 deletions(-) diff --git a/lib/widgets/dialogs/lineup_panel_dialog.dart b/lib/widgets/dialogs/lineup_panel_dialog.dart index f4aeacd4..c17b8047 100644 --- a/lib/widgets/dialogs/lineup_panel_dialog.dart +++ b/lib/widgets/dialogs/lineup_panel_dialog.dart @@ -147,7 +147,8 @@ class _LineUpPanelDialogState extends ConsumerState { color: Colors.transparent, child: CustomTextField( controller: controller, - hintText: 'Optional, for telling this apart from other lineups here', + hintText: + 'Optional, for telling this apart from other lineups here', ), ), ), @@ -165,7 +166,6 @@ class _LineUpPanelDialogState extends ConsumerState { @override Widget build(BuildContext context) { - const theme = Settings.tacticalVioletTheme; final state = ref.watch(lineUpProvider); final links = _links(state); @@ -184,9 +184,8 @@ class _LineUpPanelDialogState extends ConsumerState { _selectedLinkId = selected.id; } - final landing = widget.landingId == null - ? null - : state.landingById(widget.landingId!); + final landing = + widget.landingId == null ? null : state.landingById(widget.landingId!); final origin = widget.originId == null ? null : state.originById(widget.originId!); final title = landing != null @@ -196,6 +195,12 @@ class _LineUpPanelDialogState extends ConsumerState { ? '${links.length} lineups land here' : '${links.length} lineups from here'; + // The media is the point of this dialog, so it takes most of the window + // and gives the pane whatever the list doesn't need. + final window = MediaQuery.sizeOf(context); + final bodyWidth = (window.width - 120).clamp(640.0, 1400.0); + final bodyHeight = (window.height - 200).clamp(360.0, 900.0); + return CallbackShortcuts( bindings: { const SingleActivator(LogicalKeyboardKey.escape): () { @@ -207,17 +212,17 @@ class _LineUpPanelDialogState extends ConsumerState { child: ShadDialog( title: Text(title), description: Text(subtitle), - constraints: const BoxConstraints(maxWidth: 960), + constraints: BoxConstraints(maxWidth: bodyWidth + 48), child: SizedBox( - width: 900, - height: 520, + width: bodyWidth, + height: bodyHeight, child: Material( color: Colors.transparent, child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SizedBox( - width: 280, + width: 240, child: ListView.separated( itemCount: links.length, separatorBuilder: (_, __) => const SizedBox(height: 8), @@ -260,7 +265,9 @@ class _LineUpPanelDialogState extends ConsumerState { }, onRename: () => _rename(link), onDelete: () { - ref.read(lineUpProvider.notifier).deleteLink(link.id); + ref + .read(lineUpProvider.notifier) + .deleteLink(link.id); }, ); }, @@ -268,18 +275,12 @@ class _LineUpPanelDialogState extends ConsumerState { ), const SizedBox(width: 16), Expanded( - child: Container( - decoration: BoxDecoration( - color: theme.card, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: theme.border), - ), - clipBehavior: Clip.antiAlias, + child: ClipRRect( + borderRadius: BorderRadius.circular(8), child: LineUpMediaPages( key: ValueKey(selected.id), images: selected.images, youtubeLink: selected.youtubeLink, - padding: const EdgeInsets.all(48), ), ), ), diff --git a/lib/widgets/dialogs/upload_image_dialog.dart b/lib/widgets/dialogs/upload_image_dialog.dart index bf4859c1..6edeac30 100644 --- a/lib/widgets/dialogs/upload_image_dialog.dart +++ b/lib/widgets/dialogs/upload_image_dialog.dart @@ -259,7 +259,9 @@ class _UploadDropSquare extends StatelessWidget { Positioned.fill( child: Container( decoration: BoxDecoration( - color: Settings.tacticalVioletTheme.card, + // A step below the dialog: the square is a place to + // drop into, not a card to read. + color: Settings.tacticalVioletTheme.background, border: Border.all( color: Settings.tacticalVioletTheme.border, width: 1, @@ -391,7 +393,7 @@ class _SelectionFooter extends StatelessWidget { return DecoratedBox( decoration: BoxDecoration( - color: Settings.tacticalVioletTheme.background, + color: Settings.tacticalVioletTheme.card, borderRadius: BorderRadius.circular(12), boxShadow: const [ Settings.cardForegroundBackdrop, diff --git a/lib/widgets/editor_toolbar.dart b/lib/widgets/editor_toolbar.dart index 7f984be6..4e03b301 100644 --- a/lib/widgets/editor_toolbar.dart +++ b/lib/widgets/editor_toolbar.dart @@ -48,67 +48,66 @@ class _EditorToolbarState extends ConsumerState { 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.upload200), - ), - EditorToolbarButton( - style: style, - tooltip: 'Export video', - onPressed: _exportVideo, - icon: const Icon(LucideIcons.clapperboard200), - ), - 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, - ), + // The strategy view owns the spacing around this card, so it aligns + // with the map card above it. + return 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.upload200), + ), + EditorToolbarButton( + style: style, + tooltip: 'Export video', + onPressed: _exportVideo, + icon: const Icon(LucideIcons.clapperboard200), + ), + 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.camera200), - ), - const EditorToolbarDivider(), - EditorToolbarButton( - style: style, - tooltip: 'Settings', - onPressed: () { - showShadDialog( - context: context, - builder: (context) => const SettingsTab(), - ); - }, - icon: const Icon(LucideIcons.settings200), - ), - ], - ), + ), + ) + : const Icon(LucideIcons.camera200), + ), + const EditorToolbarDivider(), + EditorToolbarButton( + style: style, + tooltip: 'Settings', + onPressed: () { + showShadDialog( + context: context, + builder: (context) => const SettingsTab(), + ); + }, + icon: const Icon(LucideIcons.settings200), + ), + ], ), - ], - ), + ), + ], ); } From 9e1f55e997708ccd2fc6a447d384a9c20bd3f9ea Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Tue, 15 Sep 2026 19:12:48 -0700 Subject: [PATCH 05/11] Fold the weapon categories under one Weapon entry The agent context menu shows a single Weapon item whose submenu holds the categories. None leads that submenu only while a weapon is equipped. Co-Authored-By: Claude Fable 5.1 --- .../agents/agent_weapon_menu.dart | 45 ++++++++++++------- 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/lib/widgets/draggable_widgets/agents/agent_weapon_menu.dart b/lib/widgets/draggable_widgets/agents/agent_weapon_menu.dart index 067bf107..08f23270 100644 --- a/lib/widgets/draggable_widgets/agents/agent_weapon_menu.dart +++ b/lib/widgets/draggable_widgets/agents/agent_weapon_menu.dart @@ -3,10 +3,14 @@ import 'package:icarus/const/weapons.dart'; import 'package:icarus/widgets/draggable_widgets/agents/weapon_icon.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; +/// One "Weapon" entry whose submenu holds the categories. "None" leads the +/// submenu only while a weapon is equipped, since it is a no-op otherwise. List buildAgentWeaponMenu({ required WeaponType selectedWeapon, required ValueChanged onSelected, }) { + final equipped = selectedWeapon != WeaponType.none; + Widget selectionMark(WeaponType weapon) => SizedBox( width: 16, height: 16, @@ -16,24 +20,31 @@ List buildAgentWeaponMenu({ ); return [ - for (final category in WeaponCategory.values) - ShadContextMenuItem( - trailing: const Icon(LucideIcons.chevronRight, size: 16), - items: [ - for (final weapon in category.weapons) - ShadContextMenuItem( - leading: WeaponIcon(weapon: weapon, width: 36, height: 20), - trailing: selectionMark(weapon), - onPressed: () => onSelected(weapon), - child: Text(weapon.displayName), - ), - ], - child: Text(category.label), - ), ShadContextMenuItem( - trailing: selectionMark(WeaponType.none), - onPressed: () => onSelected(WeaponType.none), - child: const Text('None'), + leading: const Icon(LucideIcons.crosshair), + trailing: const Icon(LucideIcons.chevronRight, size: 16), + items: [ + if (equipped) + ShadContextMenuItem( + onPressed: () => onSelected(WeaponType.none), + child: const Text('None'), + ), + for (final category in WeaponCategory.values) + ShadContextMenuItem( + trailing: const Icon(LucideIcons.chevronRight, size: 16), + items: [ + for (final weapon in category.weapons) + ShadContextMenuItem( + leading: WeaponIcon(weapon: weapon, width: 36, height: 20), + trailing: selectionMark(weapon), + onPressed: () => onSelected(weapon), + child: Text(weapon.displayName), + ), + ], + child: Text(category.label), + ), + ], + child: const Text('Weapon'), ), ]; } From 9ddc97b4a2f11cb3bdc1a0a171206a9f4a89557a Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Tue, 15 Sep 2026 19:25:52 -0700 Subject: [PATCH 06/11] Split the library's New button The body reads New Strategy and opens the map picker in one click. The chevron opens the menu, which now leads with New Folder and keeps the import and export items. Each half spells out the raised decoration in full, since merging a partial one drops the theme's gradient, and the seam is its own strip because a rounded border must be one colour. Co-Authored-By: Claude Fable 5.1 --- lib/widgets/library_title_strip.dart | 61 +++++++++++++++++++++------- 1 file changed, 46 insertions(+), 15 deletions(-) diff --git a/lib/widgets/library_title_strip.dart b/lib/widgets/library_title_strip.dart index 26ba9d77..8248e0be 100644 --- a/lib/widgets/library_title_strip.dart +++ b/lib/widgets/library_title_strip.dart @@ -183,6 +183,18 @@ class _LibraryTitleStripState extends ConsumerState { ); } + /// The primary button's raised look with one side's corners squared off. + /// Spelled out in full because merging a partial decoration drops the + /// theme's gradient and shadow. + static ShadDecoration _halfDecoration(BorderRadius radius) => ShadDecoration( + gradient: Settings.raisedPrimaryFill, + shadows: const [Settings.raisedDropShadow], + border: ShadBorder( + radius: radius, + top: const ShadBorderSide(color: Settings.raisedTopLight, width: 1), + ), + ); + Widget _buildNewMenu() { const showLibraryTools = !kIsWeb; return ShadPopover( @@ -199,13 +211,6 @@ class _LibraryTitleStripState extends ConsumerState { 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'), @@ -237,14 +242,40 @@ class _LibraryTitleStripState extends ConsumerState { ], ), ), - 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'), + // A split button: the body goes straight to the map picker, the + // chevron opens everything else. Each half keeps only its outer corners. + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + ShadButton( + key: const ValueKey('library-new-strategy'), + height: _controlHeight, + padding: const EdgeInsets.only(left: 8, right: 10), + decoration: _halfDecoration( + const BorderRadius.horizontal(left: Radius.circular(6)), + ), + onPressed: widget.onCreateStrategy, + leading: const Icon(LucideIcons.plus, size: 16), + child: const Text('New Strategy'), + ), + // A rounded border must be one colour, so the seam is its own strip. + const SizedBox( + width: 1, + height: _controlHeight, + child: ColoredBox(color: Settings.raisedBottomShade), + ), + ShadIconButton( + key: const ValueKey('library-new-menu'), + width: 24, + height: _controlHeight, + padding: EdgeInsets.zero, + decoration: _halfDecoration( + const BorderRadius.horizontal(right: Radius.circular(6)), + ), + onPressed: _newController.toggle, + icon: const Icon(LucideIcons.chevronDown, size: 14), + ), + ], ), ); } From 477ed041c5f65ba5f93743f95bac5abd8490a8db Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Tue, 15 Sep 2026 19:33:42 -0700 Subject: [PATCH 07/11] Collapse the library search to a bare icon At rest the search is a muted icon the same size as the sort ghost beside it. The bordered, filled field appears only once it expands. Co-Authored-By: Claude Fable 5.1 --- lib/widgets/custom_search_field.dart | 19 ++++++++++++------- lib/widgets/library_title_strip.dart | 2 +- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/lib/widgets/custom_search_field.dart b/lib/widgets/custom_search_field.dart index 37eeff9b..a4fdf6ab 100644 --- a/lib/widgets/custom_search_field.dart +++ b/lib/widgets/custom_search_field.dart @@ -146,17 +146,20 @@ class _SearchTextFieldState extends ConsumerState { decoration: InputDecoration( isDense: compact, contentPadding: contentPadding, + // Collapsed, it is a bare icon like the ghost buttons beside it; the + // box only appears once there is a field to type in. enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(8.0), - borderSide: - BorderSide(color: Settings.tacticalVioletTheme.border, width: 1), + borderSide: _expanded + ? BorderSide(color: Settings.tacticalVioletTheme.border, width: 1) + : BorderSide.none, ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(8.0), borderSide: BorderSide(color: Settings.tacticalVioletTheme.ring, width: 2), ), - filled: true, + filled: _expanded, fillColor: Settings.tacticalVioletTheme.card, border: OutlineInputBorder( borderRadius: BorderRadius.circular(8.0), @@ -173,13 +176,15 @@ class _SearchTextFieldState extends ConsumerState { : const EdgeInsets.only(left: 12, right: 8), child: Icon( LucideIcons.search, - color: Colors.white, - size: compact ? 18 : 20, + color: _expanded + ? Colors.white + : Settings.tacticalVioletTheme.mutedForeground, + size: compact ? 16 : 20, ), ), prefixIconConstraints: BoxConstraints( - minWidth: compact ? 40 : 40, - minHeight: compact ? 40 : 40, + minWidth: compact ? 28 : 40, + minHeight: compact ? 28 : 40, ), suffixIcon: _hasText ? IconButton( diff --git a/lib/widgets/library_title_strip.dart b/lib/widgets/library_title_strip.dart index 8248e0be..d3801d3c 100644 --- a/lib/widgets/library_title_strip.dart +++ b/lib/widgets/library_title_strip.dart @@ -105,7 +105,7 @@ class _LibraryTitleStripState extends ConsumerState { height: _controlHeight, child: SearchTextField( key: ValueKey('library-search'), - collapsedWidth: 34, + collapsedWidth: _controlHeight, expandedWidth: 220, compact: true, hintText: 'Search', From e3d8f24bcbd336faf44b5d44d283c74b457f9243 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Tue, 15 Sep 2026 19:37:56 -0700 Subject: [PATCH 08/11] Say so when Shared or Community is tapped The placeholder tabs now answer a tap with a toast that they aren't ready yet and are coming soon, alongside the hover tooltip they already had. Co-Authored-By: Claude Fable 5.1 --- lib/widgets/library_title_strip.dart | 32 ++++++++++++++++++---------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/lib/widgets/library_title_strip.dart b/lib/widgets/library_title_strip.dart index d3801d3c..9caa44e6 100644 --- a/lib/widgets/library_title_strip.dart +++ b/lib/widgets/library_title_strip.dart @@ -74,22 +74,24 @@ class _LibraryTitleStripState extends ConsumerState { 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'), + _TabButton( + key: const ValueKey('library-tab-shared'), icon: LucideIcons.users, label: 'Shared', semanticsLabel: 'Shared library', selected: false, - dimmed: true, + comingSoon: true, + onTap: () => _comingSoon('Shared libraries'), ), const SizedBox(width: _tabGap), - const _TabButton( - key: ValueKey('library-tab-community'), + _TabButton( + key: const ValueKey('library-tab-community'), icon: LucideIcons.globe, label: 'Community', semanticsLabel: 'Community library', selected: false, - dimmed: true, + comingSoon: true, + onTap: () => _comingSoon('The community library'), ), if (kIsWeb) ...[ const SizedBox(width: 8), @@ -121,6 +123,13 @@ class _LibraryTitleStripState extends ConsumerState { ); } + void _comingSoon(String what) { + Settings.showToast( + message: "$what aren't ready yet. Coming very soon.", + backgroundColor: Settings.tacticalVioletTheme.primary, + ); + } + Widget _buildSortMenu() { final filter = ref.watch(strategyFilterProvider); final isAscending = filter.sortOrder == SortOrder.ascending; @@ -289,16 +298,17 @@ class _TabButton extends StatelessWidget { required this.semanticsLabel, required this.selected, this.onTap, - this.dimmed = false, + this.comingSoon = 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. + /// Dims the tab and says so on hover; the tap should explain itself too. + final bool comingSoon; + final VoidCallback? onTap; @override @@ -312,7 +322,7 @@ class _TabButton extends StatelessWidget { enabled: onTap != null, onTap: onTap, child: Opacity( - opacity: dimmed ? 0.45 : 1, + opacity: comingSoon ? 0.45 : 1, child: DecoratedBox( decoration: selected ? Settings.raisedSurface(_tabRadius) @@ -336,7 +346,7 @@ class _TabButton extends StatelessWidget { ), ), ); - if (onTap != null) return button; + if (!comingSoon) return button; return ShadTooltip( builder: (context) => const Text('Coming soon'), child: button, From dace18670ec666c3f8bd34f63768a840cd15cee2 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:10:12 -0700 Subject: [PATCH 09/11] Open the editor the moment a map is picked The create dialog now hands back the map and closes at once. The library creates the strategy and pushes the same route the tiles use, so the editor paints on the chosen map right away and loads inside itself, instead of loading every provider first and then fading the view in. The navigator's load-then-push helper had no other callers and is gone. Co-Authored-By: Claude Fable 5.1 --- lib/providers/strategy_provider.dart | 8 +- .../strategy/create_strategy_dialog.dart | 108 +++++++----------- lib/widgets/folder_navigator.dart | 74 +++++------- 3 files changed, 73 insertions(+), 117 deletions(-) diff --git a/lib/providers/strategy_provider.dart b/lib/providers/strategy_provider.dart index 86b9983c..010069b9 100644 --- a/lib/providers/strategy_provider.dart +++ b/lib/providers/strategy_provider.dart @@ -3353,9 +3353,9 @@ class StrategyProvider extends Notifier { ); } - /// Creates an empty strategy on [map] and returns its id. Without [name] - /// it is auto-named after the map ("Haven", then "Haven 2", ...). - Future createNewStrategy({ + /// Creates an empty strategy on [map] and returns it. Without [name] it + /// is auto-named after the map ("Haven", then "Haven 2", ...). + Future createNewStrategy({ required MapValue map, String? name, }) async { @@ -3406,7 +3406,7 @@ class StrategyProvider extends Notifier { unawaited(AnalyticsService.instance.capture('strategy_created')); - return newStrategy.id; + return newStrategy; } void setThemeProfileForCurrentStrategy(String profileId) { diff --git a/lib/widgets/dialogs/strategy/create_strategy_dialog.dart b/lib/widgets/dialogs/strategy/create_strategy_dialog.dart index 00fa87d9..44946447 100644 --- a/lib/widgets/dialogs/strategy/create_strategy_dialog.dart +++ b/lib/widgets/dialogs/strategy/create_strategy_dialog.dart @@ -4,7 +4,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/maps.dart'; import 'package:icarus/const/settings.dart'; -import 'package:icarus/providers/strategy_provider.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; const double _tileWidth = 220; @@ -13,8 +12,8 @@ const double _tileGap = 8; const int _columns = 4; const double _gridWidth = _columns * _tileWidth + (_columns - 1) * _tileGap; -/// Creating a strategy is picking its map. One tap on a tile creates the -/// strategy, auto-named after the map, and pops with its id. +/// Creating a strategy is picking its map. One tap on a tile pops with the +/// map; the library creates the strategy and opens it. class CreateStrategyDialog extends ConsumerStatefulWidget { const CreateStrategyDialog({super.key}); @@ -24,29 +23,13 @@ class CreateStrategyDialog extends ConsumerStatefulWidget { } class _CreateStrategyDialogState extends ConsumerState { - bool _isSubmitting = false; bool _showOutOfRotation = false; MapValue? _hoveredMap; static List _sorted(List maps) => maps.toList() ..sort((a, b) => Maps.displayName(a).compareTo(Maps.displayName(b))); - Future _create(MapValue map) async { - if (_isSubmitting) return; - setState(() => _isSubmitting = true); - try { - final strategyID = - await ref.read(strategyProvider.notifier).createNewStrategy(map: map); - 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, - ); - } - } + void _pick(MapValue map) => Navigator.of(context).pop(map); Widget _grid(List maps) { return Wrap( @@ -64,7 +47,7 @@ class _CreateStrategyDialogState extends ConsumerState { () => _hoveredMap = over ? map : (_hoveredMap == map ? null : _hoveredMap), ), - onTap: () => _create(map), + onTap: () => _pick(map), ), ], ); @@ -79,52 +62,45 @@ class _CreateStrategyDialogState extends ConsumerState { constraints: const BoxConstraints(maxWidth: _gridWidth + 48), child: Material( color: Colors.transparent, - child: IgnorePointer( - ignoring: _isSubmitting, - child: AnimatedOpacity( - duration: const Duration(milliseconds: 150), - opacity: _isSubmitting ? 0.6 : 1, - child: SizedBox( - width: _gridWidth, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(height: 8), - _grid(_sorted(Maps.availableMaps)), - const SizedBox(height: 12), - ShadButton.ghost( - key: const ValueKey('create-strategy-out-of-rotation'), - size: ShadButtonSize.sm, - padding: EdgeInsets.zero, - onPressed: () => setState( - () => _showOutOfRotation = !_showOutOfRotation, - ), - trailing: AnimatedRotation( - duration: const Duration(milliseconds: 120), - turns: _showOutOfRotation ? 0.5 : 0, - child: const Icon(LucideIcons.chevronDown, size: 14), - ), - child: Text( - 'Out of rotation', - style: theme.textTheme.small - .copyWith(color: theme.colorScheme.mutedForeground), - ), - ), - AnimatedSize( - duration: const Duration(milliseconds: 120), - curve: Curves.easeOutCubic, - alignment: Alignment.topCenter, - child: _showOutOfRotation - ? Padding( - padding: const EdgeInsets.only(top: 8), - child: _grid(_sorted(Maps.outofplayMaps)), - ) - : const SizedBox(width: _gridWidth), - ), - ], + child: SizedBox( + width: _gridWidth, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 8), + _grid(_sorted(Maps.availableMaps)), + const SizedBox(height: 12), + ShadButton.ghost( + key: const ValueKey('create-strategy-out-of-rotation'), + size: ShadButtonSize.sm, + padding: EdgeInsets.zero, + onPressed: () => setState( + () => _showOutOfRotation = !_showOutOfRotation, + ), + trailing: AnimatedRotation( + duration: const Duration(milliseconds: 120), + turns: _showOutOfRotation ? 0.5 : 0, + child: const Icon(LucideIcons.chevronDown, size: 14), + ), + child: Text( + 'Out of rotation', + style: theme.textTheme.small + .copyWith(color: theme.colorScheme.mutedForeground), + ), ), - ), + AnimatedSize( + duration: const Duration(milliseconds: 120), + curve: Curves.easeOutCubic, + alignment: Alignment.topCenter, + child: _showOutOfRotation + ? Padding( + padding: const EdgeInsets.only(top: 8), + child: _grid(_sorted(Maps.outofplayMaps)), + ) + : const SizedBox(width: _gridWidth), + ), + ], ), ), ), diff --git a/lib/widgets/folder_navigator.dart b/lib/widgets/folder_navigator.dart index f6a6e1fe..26b18770 100644 --- a/lib/widgets/folder_navigator.dart +++ b/lib/widgets/folder_navigator.dart @@ -5,11 +5,11 @@ import 'package:flutter/foundation.dart' show debugPrint, kDebugMode, kIsWeb; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/coordinate_system.dart'; -import 'package:icarus/const/routes.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/const/update_checker.dart'; import 'package:icarus/main.dart'; import 'package:icarus/providers/folder_provider.dart'; +import 'package:icarus/const/maps.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/providers/update_status_provider.dart'; import 'package:icarus/services/app_error_reporter.dart'; @@ -234,45 +234,6 @@ class _FolderNavigatorState extends ConsumerState { final currentFolder = currentFolderId != null ? ref.read(folderProvider.notifier).findFolderByID(currentFolderId) : null; - Future navigateWithLoading( - BuildContext context, String strategyId) async { - // Show loading overlay - // showLoadingOverlay(context); - - try { - await ref.read(strategyProvider.notifier).loadFromHive(strategyId); - - if (!context.mounted) return; - - Navigator.push( - context, - PageRouteBuilder( - settings: const RouteSettings(name: Routes.strategyView), - transitionDuration: const Duration(milliseconds: 200), - reverseTransitionDuration: - const Duration(milliseconds: 200), // pop duration - pageBuilder: (context, animation, secondaryAnimation) => - const StrategyView(), - transitionsBuilder: - (context, animation, secondaryAnimation, child) { - return FadeTransition( - opacity: animation, - child: ScaleTransition( - scale: Tween(begin: 0.9, end: 1.0) - .chain(CurveTween(curve: Curves.easeOut)) - .animate(animation), - child: child, - ), - ); - }, - ), - ); - } catch (e) { - // Handle errors - // Show error message - } - } - Future showCreateFolderDialog() async { await showDialog( context: context, @@ -282,18 +243,37 @@ class _FolderNavigatorState extends ConsumerState { ); } + /// Pick a map, then go straight to the editor: the view paints on the + /// chosen map at once and loads the new strategy inside itself. void showCreateDialog() async { - final String? strategyId = await showDialog( + final map = await showDialog( context: context, - builder: (context) { - return const CreateStrategyDialog(); - }, + builder: (context) => const CreateStrategyDialog(), ); + if (map == null || !context.mounted) return; - if (strategyId != null) { - if (!context.mounted) return; - await navigateWithLoading(context, strategyId); + final StrategyData strategy; + try { + strategy = await ref + .read(strategyProvider.notifier) + .createNewStrategy(map: map); + } catch (_) { + Settings.showToast( + message: "Couldn't create strategy right now.", + backgroundColor: Settings.tacticalVioletTheme.destructive, + ); + return; } + if (!context.mounted) return; + + Navigator.push( + context, + StrategyView.route( + initialStrategyId: strategy.id, + initialStrategyName: strategy.name, + initialMapValue: strategy.mapData, + ), + ); } return Stack( From 011dd832126c7fde491887cbbc3627dcedac3939 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Wed, 16 Sep 2026 13:06:52 -0700 Subject: [PATCH 10/11] Let the side toggle's tooltip fire ShadTooltip only follows hover through Shad's own buttons, so the toggle sat on an InkWell with a tooltip that never showed. It is a ghost ShadButton now, same footprint and hover wash, sized by the map card. Co-Authored-By: Claude Fable 5.1 --- lib/widgets/map_selector.dart | 44 ++++++++++++++++++++++++----------- 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/lib/widgets/map_selector.dart b/lib/widgets/map_selector.dart index 3b7b1881..09f70bfb 100644 --- a/lib/widgets/map_selector.dart +++ b/lib/widgets/map_selector.dart @@ -221,9 +221,10 @@ class _MapSelectorState extends ConsumerState { ), ), const SizedBox(width: _innerGap), - const SizedBox( + const _SideToggle( width: _sideToggleWidth, - child: _SideToggle(borderRadius: _innerRadius), + height: _cardHeight - 2 * (_borderWidth + _innerGap), + borderRadius: _innerRadius, ), ], ), @@ -237,8 +238,14 @@ class _MapSelectorState extends ConsumerState { /// Shift+click applies to this page only, which is how a strategy becomes /// mixed. When it is mixed, a dot warns that a plain click will unify it. class _SideToggle extends ConsumerWidget { - const _SideToggle({required this.borderRadius}); + const _SideToggle({ + required this.width, + required this.height, + required this.borderRadius, + }); + final double width; + final double height; final double borderRadius; @override @@ -260,19 +267,28 @@ class _SideToggle extends ConsumerWidget { 'Shift+click: this page only' : 'Switch side on all pages\nShift+click: this page only'; + // A Shad button, not an InkWell: ShadTooltip only follows hover + // through Shad's own buttons. return ShadTooltip( builder: (context) => Text(tooltip), - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: () { - ref.read(strategyProvider.notifier).switchSide( - allPages: !HardwareKeyboard.instance.isShiftPressed, - ); - }, - mouseCursor: SystemMouseCursors.click, - borderRadius: BorderRadius.circular(borderRadius), - hoverColor: Colors.white.withValues(alpha: 0.08), + child: ShadButton.ghost( + width: width, + height: height, + padding: EdgeInsets.zero, + hoverBackgroundColor: Colors.white.withValues(alpha: 0.08), + decoration: ShadDecoration( + border: ShadBorder.all( + radius: BorderRadius.circular(borderRadius), + ), + ), + onPressed: () { + ref.read(strategyProvider.notifier).switchSide( + allPages: !HardwareKeyboard.instance.isShiftPressed, + ); + }, + child: SizedBox( + width: width, + height: height, child: Stack( // Fill the toggle so the column centres in the box, not in // the width of its own label. From 7b4c4eb5bf4da7f129d774e8ec98f6b23c069dc6 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Wed, 16 Sep 2026 13:06:52 -0700 Subject: [PATCH 11/11] Keep the active page's side on the save path when switching all pages The all-pages switch flushed the active page to Hive first, so a plain side click wrote every pending edit to disk and "Don't save" could not take the side change back. Now only the other pages are flipped in place, which is how they are held between visits anyway, and the active page's side rides on the normal save like the this-page switch already did. Co-Authored-By: Claude Fable 5.1 --- lib/providers/strategy_provider.dart | 13 ++++++++++--- test/strategy_switch_side_test.dart | 18 +++++++++++++++++- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/lib/providers/strategy_provider.dart b/lib/providers/strategy_provider.dart index 010069b9..28709dac 100644 --- a/lib/providers/strategy_provider.dart +++ b/lib/providers/strategy_provider.dart @@ -4037,21 +4037,28 @@ class StrategyProvider extends Notifier { /// attack-canonical, so the only thing that changes is each page's side. /// With [allPages] every page takes the active page's new side; otherwise /// only the active page changes and the strategy may become mixed. + /// Flips the side of the active page, or of every page when [allPages]. + /// + /// The active page's side lives in [mapProvider] and reaches Hive through + /// the normal save, so "Don't save" still reverts it and none of its other + /// pending edits are flushed here. The other pages are held in Hive + /// between visits (a page switch writes there the same way), so they are + /// flipped in place. Future switchSide({required bool allPages}) async { final isAttack = !ref.read(mapProvider).isAttack; ref.read(mapProvider.notifier).setAttack(isAttack); setUnsaved(); if (!allPages || state.stratName == null) return; - await _syncCurrentPageToHive(); - final box = Hive.box(HiveBoxNames.strategiesBox); final strat = box.get(state.id); if (strat == null || strat.pages.isEmpty) return; + final activeId = activePageID ?? strat.pages.first.id; final updated = strat.copyWith( pages: [ - for (final page in strat.pages) page.copyWith(isAttack: isAttack), + for (final page in strat.pages) + page.id == activeId ? page : page.copyWith(isAttack: isAttack), ], lastEdited: DateTime.now(), ); diff --git a/test/strategy_switch_side_test.dart b/test/strategy_switch_side_test.dart index daff68bc..0e902457 100644 --- a/test/strategy_switch_side_test.dart +++ b/test/strategy_switch_side_test.dart @@ -125,13 +125,29 @@ void main() { .switchSide(allPages: true); expect(container.read(mapProvider).isAttack, isTrue); - final pages = storedPages(); + expect(container.read(strategyProvider).isSaved, isFalse); + expect( + storedPages().map((p) => p.isAttack).toList(), + [true, false, true], + reason: 'the active page is written on the next save, not here', + ); + + final notifier = container.read(strategyProvider.notifier); + await notifier.forceSaveNow(container.read(strategyProvider).id); + var pages = storedPages(); expect(pages.map((p) => p.isAttack), everyElement(isTrue)); expect( pages.skip(1).map((p) => p.agentData.single.position).toList(), [const Offset(20, 20), const Offset(30, 20)], reason: 'side is a view choice; canonical placements must not move', ); + + // Back the other way: the other pages flip in place, the active page + // again waits for the save. + await notifier.switchSide(allPages: true); + expect(container.read(mapProvider).isAttack, isFalse); + pages = storedPages(); + expect(pages.map((p) => p.isAttack).toList(), [false, true, false]); }); test('switching side on this page leaves the other pages alone', () async {