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/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/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/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/providers/strategy_provider.dart b/lib/providers/strategy_provider.dart index 6e2433e0..28709dac 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 it. 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,12 +3402,11 @@ 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')); - return newStrategy.id; + return newStrategy; } void setThemeProfileForCurrentStrategy(String profileId) { @@ -4026,6 +4033,38 @@ 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. + /// 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; + + 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.id == activeId ? page : page.copyWith(isAttack: isAttack), + ], + lastEdited: DateTime.now(), + ); + await box.put(updated.id, updated); + } + Future applyNeutralTeamColorsToAllPages(bool value) async { if (state.stratName == null) return; @@ -4068,3 +4107,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/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/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/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/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/strategy/create_strategy_dialog.dart b/lib/widgets/dialogs/strategy/create_strategy_dialog.dart index d320baef..44946447 100644 --- a/lib/widgets/dialogs/strategy/create_strategy_dialog.dart +++ b/lib/widgets/dialogs/strategy/create_strategy_dialog.dart @@ -1,74 +1,241 @@ +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 pops with the +/// map; the library creates the strategy and opens it. class CreateStrategyDialog extends ConsumerStatefulWidget { const CreateStrategyDialog({super.key}); @override ConsumerState createState() => - _NameStrategyDialogState(); + _CreateStrategyDialogState(); } -class _NameStrategyDialogState extends ConsumerState { - final TextEditingController _textController = TextEditingController(); - bool _isSubmitting = false; +class _CreateStrategyDialogState extends ConsumerState { + 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 { - if (_isSubmitting) return; - final strategyName = _textController.text.trim(); - if (strategyName.isEmpty) { - Settings.showToast( - message: 'Strategy name cannot be empty.', - backgroundColor: Settings.tacticalVioletTheme.destructive, - ); - return; - } + void _pick(MapValue map) => Navigator.of(context).pop(map); - 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, - ); - } + 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: () => _pick(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: 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/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/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'), ), ]; } 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/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), + ), + ], ), - ], - ), + ), + ], ); } 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_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/folder_navigator.dart b/lib/widgets/folder_navigator.dart index 33a5fb99..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( @@ -323,7 +303,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/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/library_title_strip.dart b/lib/widgets/library_title_strip.dart index 31d01367..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), @@ -105,7 +107,7 @@ class _LibraryTitleStripState extends ConsumerState { height: _controlHeight, child: SearchTextField( key: ValueKey('library-search'), - collapsedWidth: 34, + collapsedWidth: _controlHeight, expandedWidth: 220, compact: true, hintText: 'Search', @@ -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; @@ -183,6 +192,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 +220,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 +251,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), + ), + ], ), ); } @@ -258,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 @@ -281,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) @@ -305,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, @@ -349,7 +390,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/map_selector.dart b/lib/widgets/map_selector.dart index 237e19ca..09f70bfb 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'; @@ -24,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 + @@ -64,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( @@ -132,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: (_, __) => @@ -180,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, @@ -223,44 +221,10 @@ class _MapSelectorState extends ConsumerState { ), ), const SizedBox(width: _innerGap), - SizedBox( + const _SideToggle( 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, - ), - ), - ], - ), - ), - ), + height: _cardHeight - 2 * (_borderWidth + _innerGap), + borderRadius: _innerRadius, ), ], ), @@ -269,3 +233,103 @@ 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.width, + required this.height, + required this.borderRadius, + }); + + final double width; + final double height; + 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'; + + // A Shad button, not an InkWell: ShadTooltip only follows hover + // through Shad's own buttons. + return ShadTooltip( + builder: (context) => Text(tooltip), + 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. + fit: StackFit.expand, + 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/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/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() 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'); + }); + }); +} diff --git a/test/strategy_switch_side_test.dart b/test/strategy_switch_side_test.dart new file mode 100644 index 00000000..0e902457 --- /dev/null +++ b/test/strategy_switch_side_test.dart @@ -0,0 +1,166 @@ +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); + 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 { + 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', + ); + }); +}