diff --git a/DESIGN.md b/DESIGN.md index 3a4f89f1..9fa8aa8e 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -19,13 +19,18 @@ The palette, theme, and sizing constants live in `lib/const/settings.dart`, with - The library strip holds the three tabs on the left and only search, sort, and New on the right (there is no account yet). Nothing else goes in it. Inside a folder, the breadcrumb lives in the content area, not the strip. - The editor's document actions (save, export, video, screenshot, settings) sit in one card at the top-left of the canvas (`lib/widgets/editor_toolbar.dart`). No status chips or labels in the editor. +## Icons + +- Lucide is the icon family (`LucideIcons.*`, re-exported by shadcn_ui). Material `Icons.*` only survive in the persisted folder-icon registry and cursor glyphs. Toolbar and tool glyphs are 18-22px, menu and inline glyphs 16px. +- Icons rest in muted foreground and come up to foreground on hover. Violet on an icon means it is the selected or checked item, nothing else. + ## Things I would like to remain consistent **The One Command Color** Violet marks current action, selection, focus, and primary commands, and nothing else. If violet appears somewhere that isn't actionable or active, it's wrong. **The Tactical Semantics** Ally green, enemy red, defender blue, favorite amber, and the map ember hues carry game meaning. Never reuse them for unrelated UI emphasis. -**The Tonal First** Depth comes from surface steps (background, panel, raised) and 1px zinc borders. A shadow is only allowed where it explains stacking: drag previews, floating menus, card foreground details (`0 4px 12px rgba(0,0,0,0.54)` / `0 8px 24px rgba(0,0,0,0.28)`). +**The Tonal First** Depth comes from surface steps (background, panel, raised) and 1px zinc borders. A shadow is only allowed where it explains stacking: drag previews, floating menus, card foreground details (`0 4px 12px rgba(0,0,0,0.54)` / `0 8px 24px rgba(0,0,0,0.28)`). A selected or primary state is never a flat fill: it is a raised surface, lit from above. The fill runs lighter at the top, a 1px light sits inside the top edge, a 1px shade inside the bottom, and a 1px shadow drops beneath; the sides stay bare. `Settings.raised(color, radius)` builds it for any base color (`raisedPrimary` and `raisedSurface` are the violet and zinc shortcuts), painted by `InsetShadowDecoration` (`lib/widgets/inset_shadow_decoration.dart`), which also tweens in animated containers. Primary buttons get it from the Shad theme. Hover stays flat. **Every control earns its position.** If you can't say why a control sits where it sits, it isn't done. Never fill spare space with a feature. diff --git a/lib/const/settings.dart b/lib/const/settings.dart index 2b8b9122..e42253c8 100644 --- a/lib/const/settings.dart +++ b/lib/const/settings.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:icarus/const/color_option.dart'; +import 'package:icarus/widgets/inset_shadow_decoration.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:toastification/toastification.dart'; @@ -226,6 +227,79 @@ class Settings { static const Color settingsDiscordAccent = Color(0xff5865f2); // brand blurple static const Color settingsMapAccent = Color(0xffb27c40); // map layers + // Resting glyph color for toolbar controls: a step under foreground so the + // strip of icons stays quiet, but above mutedForeground, which vanishes at + // the light stroke weights. Hover still comes up to foreground. + static const Color toolbarGlyph = Color(0xffd4d4d8); // zinc-300 + + // Raised surfaces (a selected tab, a checked tool, a primary command) are + // lit from above: the fill runs lighter at the top, a bright 1px edge sits + // inside the top, a dark 1px edge inside the bottom, and a 1px shadow drops + // beneath. The sides stay bare. Everything paints inside or 1px under the + // box, so the footprint never changes. Hover stays flat. + static const Color raisedTopLight = Color(0x24ffffff); // white 14% + static const Color raisedBottomShade = Color(0x4d000000); // black 30% + static const List raisedRim = [ + InsetShadow(color: raisedTopLight, offset: Offset(0, 1)), + InsetShadow(color: raisedBottomShade, offset: Offset(0, -1)), + ]; + static const BoxShadow raisedDropShadow = BoxShadow( + color: Color(0x73000000), // black 45% + offset: Offset(0, 1), + ); + // How far the fill's top and bottom move from the base color, in HSL + // lightness. These two numbers set the lift for every raised surface. + static const double raisedTopLift = 0.06; + static const double raisedBottomDrop = 0.03; + + /// The lit fill for any base color: lighter at the top, darker at the + /// bottom, so one recipe serves violet, zinc, red, and the rest. + static LinearGradient raisedGradient(Color base) { + final hsl = HSLColor.fromColor(base); + return LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + hsl + .withLightness((hsl.lightness + raisedTopLift).clamp(0, 1)) + .toColor(), + hsl + .withLightness((hsl.lightness - raisedBottomDrop).clamp(0, 1)) + .toColor(), + ], + ); + } + + /// A raised surface of [base] color at [radius]. Use this wherever a + /// selected or primary state would otherwise be a flat fill. + static InsetShadowDecoration raised(Color base, double radius) => + InsetShadowDecoration( + gradient: raisedGradient(base), + borderRadius: BorderRadius.circular(radius), + boxShadows: const [raisedDropShadow], + shadows: raisedRim, + ); + + /// The raised neutral surface: a selected tab or chip. + static InsetShadowDecoration raisedSurface(double radius) => + raised(tacticalVioletTheme.secondary, radius); + + /// The raised command surface: a checked tool, the active segment, the + /// active page, anything that would otherwise be a flat `primary` fill. + static InsetShadowDecoration raisedPrimary(double radius) => + raised(tacticalVioletTheme.primary, radius); + + /// The primary fill alone, for the Shad theme and animated fills. + static final LinearGradient raisedPrimaryFill = + raisedGradient(tacticalVioletTheme.primary); + + // The shadow a floating menu earns (DESIGN.md: 0 8px 24px rgba(0,0,0,0.28)). + static const BoxShadow floatingMenuShadow = BoxShadow( + color: Color(0x47000000), + blurRadius: 24, + offset: Offset(0, 8), + ); + static const cardForegroundBackdrop = BoxShadow( color: Colors.black54, // High opacity because the background is dark blurRadius: 12, @@ -249,13 +323,7 @@ class Settings { return Container( margin: const EdgeInsets.all(16), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: backgroundColor, - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: Settings.tacticalVioletTheme.border, - ), - ), + decoration: Settings.raised(backgroundColor, 8), child: Row( mainAxisSize: MainAxisSize.min, children: [ diff --git a/lib/main.dart b/lib/main.dart index 2ab5d91c..7e919518 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -367,6 +367,20 @@ class _MyAppState extends ConsumerState { ghostButtonTheme: 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 + // the top light only and the gradient carries the bottom shade. + primaryButtonTheme: ShadButtonTheme( + decoration: ShadDecoration( + gradient: Settings.raisedPrimaryFill, + 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/services/unsaved_strategy_guard.dart b/lib/services/unsaved_strategy_guard.dart index 374a9ca1..0b93e7a3 100644 --- a/lib/services/unsaved_strategy_guard.dart +++ b/lib/services/unsaved_strategy_guard.dart @@ -18,14 +18,11 @@ Future showUnsavedStrategyDialog( builder: (context) { return ShadDialog.alert( title: const Text('Save changes?'), - description: const Padding( - padding: EdgeInsets.all(8), - child: Text( - 'This strategy has unsaved changes. Do you want to save before leaving?', - ), + description: const Text( + 'This strategy has unsaved changes. Do you want to save before leaving?', ), actions: [ - ShadButton.secondary( + ShadButton.ghost( onPressed: () { Navigator.of(context).pop(UnsavedStrategyDecision.cancel); }, @@ -35,7 +32,7 @@ Future showUnsavedStrategyDialog( onPressed: () { Navigator.of(context).pop(UnsavedStrategyDecision.dontSave); }, - child: const Text("Don't Save"), + child: const Text("Don't save"), ), ShadButton( onPressed: () { diff --git a/lib/widgets/color_picker_button.dart b/lib/widgets/color_picker_button.dart index c1111167..38d695a1 100644 --- a/lib/widgets/color_picker_button.dart +++ b/lib/widgets/color_picker_button.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; class ColorPickerButton extends ConsumerStatefulWidget { const ColorPickerButton({ diff --git a/lib/widgets/custom_segmented_tabs.dart b/lib/widgets/custom_segmented_tabs.dart index db0d7d67..ad506724 100644 --- a/lib/widgets/custom_segmented_tabs.dart +++ b/lib/widgets/custom_segmented_tabs.dart @@ -2,6 +2,7 @@ import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/widgets/inset_shadow_decoration.dart'; enum SegmentedIndicatorBehavior { slidingPill, @@ -207,10 +208,7 @@ class _CustomSegmentedTabsState extends State> { width: _segmentWidths[selectedIndex], bottom: 0, child: DecoratedBox( - decoration: BoxDecoration( - color: Settings.tacticalVioletTheme.primary, - borderRadius: BorderRadius.circular(_segmentRadius), - ), + decoration: Settings.raisedPrimary(_segmentRadius), ), ), Row( @@ -296,12 +294,11 @@ class _TabButton extends StatelessWidget { horizontal: horizontalPadding, vertical: verticalPadding, ), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(borderRadius), - color: showStaticFill && isSelected - ? Settings.tacticalVioletTheme.primary - : Colors.transparent, - ), + decoration: showStaticFill && isSelected + ? Settings.raisedPrimary(borderRadius) + : InsetShadowDecoration( + borderRadius: BorderRadius.circular(borderRadius), + ), child: DefaultTextStyle.merge( style: TextStyle( color: textColor, diff --git a/lib/widgets/delete_area.dart b/lib/widgets/delete_area.dart index f1e3cc3a..e121611a 100644 --- a/lib/widgets/delete_area.dart +++ b/lib/widgets/delete_area.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/placed_classes.dart'; @@ -10,7 +11,6 @@ import 'package:icarus/providers/delete_menu_provider.dart'; import 'package:icarus/providers/screenshot_provider.dart'; import 'package:icarus/widgets/delete_helpers.dart'; import 'package:icarus/widgets/sidebar_widgets/delete_options.dart'; -import 'package:shadcn_ui/shadcn_ui.dart'; class DeleteArea extends ConsumerStatefulWidget { const DeleteArea({super.key}); diff --git a/lib/widgets/dialogs/strategy/rename_strategy_dialog.dart b/lib/widgets/dialogs/strategy/rename_strategy_dialog.dart index a4aaa73c..061b5a49 100644 --- a/lib/widgets/dialogs/strategy/rename_strategy_dialog.dart +++ b/lib/widgets/dialogs/strategy/rename_strategy_dialog.dart @@ -71,9 +71,9 @@ class _RenameStrategyDialogState extends ConsumerState { child: Padding( padding: const EdgeInsets.all(8.0), child: CustomTextField( - // onEnterPressed: (intent) {}, hintText: widget.currentName, controller: _textController, + autofocus: true, textAlign: TextAlign.start, onSubmitted: (value) async { if (value.isNotEmpty) { diff --git a/lib/widgets/editor_toolbar.dart b/lib/widgets/editor_toolbar.dart index f60959c8..7f984be6 100644 --- a/lib/widgets/editor_toolbar.dart +++ b/lib/widgets/editor_toolbar.dart @@ -23,10 +23,7 @@ import 'package:shadcn_ui/shadcn_ui.dart'; /// Geometry shared by every control in the editor's floating toolbar, so the /// save button matches its neighbours exactly. class EditorToolbarButtonStyle { - const EditorToolbarButtonStyle({ - this.size = 32, - this.iconSize = 18, - }); + const EditorToolbarButtonStyle({this.size = 32, this.iconSize = 18}); final double size; final double iconSize; @@ -70,13 +67,13 @@ class _EditorToolbarState extends ConsumerState { style: style, tooltip: 'Export .ica', onPressed: _exportStrategy, - icon: const Icon(LucideIcons.upload300), + icon: const Icon(LucideIcons.upload200), ), EditorToolbarButton( style: style, tooltip: 'Export video', onPressed: _exportVideo, - icon: const Icon(LucideIcons.clapperboard300), + icon: const Icon(LucideIcons.clapperboard200), ), EditorToolbarButton( style: style, @@ -93,7 +90,7 @@ class _EditorToolbarState extends ConsumerState { ), ), ) - : const Icon(LucideIcons.camera300), + : const Icon(LucideIcons.camera200), ), const EditorToolbarDivider(), EditorToolbarButton( @@ -105,7 +102,7 @@ class _EditorToolbarState extends ConsumerState { builder: (context) => const SettingsTab(), ); }, - icon: const Icon(LucideIcons.settings300), + icon: const Icon(LucideIcons.settings200), ), ], ), @@ -157,10 +154,9 @@ class _EditorToolbarState extends ConsumerState { await ref.read(strategyProvider.notifier).forceSaveNow(id); - final newStrat = Hive.box(HiveBoxNames.strategiesBox) - .values - .where((StrategyData strategy) => strategy.id == id) - .firstOrNull; + final newStrat = Hive.box( + HiveBoxNames.strategiesBox, + ).values.where((StrategyData strategy) => strategy.id == id).firstOrNull; if (newStrat == null) { if (mounted) setState(() => _isCapturingScreenshot = false); @@ -206,10 +202,7 @@ class _EditorToolbarState extends ConsumerState { screenshotView.hydrateProviders(screenshotContainer); final image = await newController.captureFromWidget( targetSize: CoordinateSystem.screenShotSize, - wrapForOffscreenCapture( - screenshotView, - container: screenshotContainer, - ), + wrapForOffscreenCapture(screenshotView, container: screenshotContainer), ); if (mounted) setState(() => _isCapturingScreenshot = false); String? outputFile = await FilePicker.platform.saveFile( @@ -237,7 +230,7 @@ class _EditorToolbarState extends ConsumerState { } } -/// One control in the editor toolbar. Glyphs are the 300 stroke weight: the +/// One control in the editor toolbar. Glyphs are the 200 stroke weight: the /// default 2px Lucide stroke reads heavy in white at 18px, and muted grey /// vanishes against the card, so the weight carries the quietness instead. [icon] is any 18px glyph, so buttons can swap /// in a spinner without changing size. @@ -266,7 +259,7 @@ class EditorToolbarButton extends StatelessWidget { @override Widget build(BuildContext context) { const theme = Settings.tacticalVioletTheme; - final resting = foregroundColor ?? theme.foreground; + final resting = foregroundColor ?? Settings.toolbarGlyph; return Semantics( label: semanticsLabel ?? tooltip, button: true, diff --git a/lib/widgets/folder_edit_dialog.dart b/lib/widgets/folder_edit_dialog.dart index ed17f34e..0b9ae7a4 100644 --- a/lib/widgets/folder_edit_dialog.dart +++ b/lib/widgets/folder_edit_dialog.dart @@ -58,6 +58,40 @@ class _FolderEditDialogState extends ConsumerState { }); } + bool _isSubmitting = false; + + /// Saves the folder and closes the dialog; Enter in the name field and the + /// Done button both land here, and a second submit while one is in flight + /// is ignored. + Future _submit() async { + if (_isSubmitting) return; + setState(() => _isSubmitting = true); + final name = _folderNameController.text.isEmpty + ? "New Folder" + : _folderNameController.text; + try { + if (widget.folder != null) { + ref.read(folderProvider.notifier).editFolder( + folder: widget.folder!, + newName: name, + newIconId: _selectedIconId, + newColor: _selectedColor, + newCustomColor: _customColor, + ); + } else { + await ref.read(folderProvider.notifier).createFolder( + name: name, + iconId: _selectedIconId, + color: _selectedColor, + customColor: _customColor, + ); + } + } finally { + if (mounted) setState(() => _isSubmitting = false); + } + if (mounted) Navigator.of(context).pop(); + } + @override Widget build(BuildContext context) { return ShadDialog( @@ -68,6 +102,10 @@ class _FolderEditDialogState extends ConsumerState { child: CustomTextField( hintText: "Folder Name", controller: _folderNameController, + autofocus: true, + onSubmitted: (_) { + if (!_isSubmitting) _submit(); + }, ), ), // const SizedBox(width: 30), @@ -75,31 +113,7 @@ class _FolderEditDialogState extends ConsumerState { padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), child: ShadButton( leading: const Icon(LucideIcons.check), - onPressed: () async { - if (widget.folder != null) { - ref.read(folderProvider.notifier).editFolder( - folder: widget.folder!, - newName: _folderNameController.text.isEmpty - ? "New Folder" - : _folderNameController.text, - newIconId: _selectedIconId, - newColor: _selectedColor, - newCustomColor: _customColor, - ); - if (context.mounted) Navigator.of(context).pop(); - return; - } - await ref.read(folderProvider.notifier).createFolder( - name: _folderNameController.text.isEmpty - ? "New Folder" - : _folderNameController.text, - iconId: _selectedIconId, - color: _selectedColor, - customColor: _customColor, - ); - - if (context.mounted) Navigator.of(context).pop(); - }, + onPressed: _isSubmitting ? null : _submit, child: const Text("Done"), ), ) diff --git a/lib/widgets/inset_shadow_decoration.dart b/lib/widgets/inset_shadow_decoration.dart new file mode 100644 index 00000000..6707228d --- /dev/null +++ b/lib/widgets/inset_shadow_decoration.dart @@ -0,0 +1,232 @@ +import 'dart:ui' as ui; + +import 'package:flutter/painting.dart'; + +/// A shadow cast inward from the edge of a box, like CSS `box-shadow: inset`. +/// +/// With [blurRadius] zero this is a crisp inner edge: a 1px [spreadRadius] +/// draws a rim all the way round, a 1px [offset] draws a lit or shaded edge on +/// one side. Flutter's [BoxShadow] cannot do either, so this pairs with +/// [InsetShadowDecoration]. +class InsetShadow { + const InsetShadow({ + required this.color, + this.offset = Offset.zero, + this.blurRadius = 0, + this.spreadRadius = 0, + }); + + final Color color; + final Offset offset; + final double blurRadius; + final double spreadRadius; + + InsetShadow _faded() => InsetShadow( + color: color.withValues(alpha: 0), + offset: offset, + blurRadius: blurRadius, + spreadRadius: spreadRadius, + ); + + static InsetShadow lerp(InsetShadow a, InsetShadow b, double t) { + return InsetShadow( + color: Color.lerp(a.color, b.color, t)!, + offset: Offset.lerp(a.offset, b.offset, t)!, + blurRadius: ui.lerpDouble(a.blurRadius, b.blurRadius, t)!, + spreadRadius: ui.lerpDouble(a.spreadRadius, b.spreadRadius, t)!, + ); + } + + /// Lerps two lists, fading in or out whatever one side lacks. + static List lerpList( + List a, + List b, + double t, + ) { + final length = a.length > b.length ? a.length : b.length; + return [ + for (var i = 0; i < length; i++) + lerp( + i < a.length ? a[i] : b[i]._faded(), + i < b.length ? b[i] : a[i]._faded(), + t, + ), + ]; + } +} + +/// A rounded box with a fill (flat or [gradient]), optional border, outer +/// [boxShadows], and any number of [InsetShadow]s painted inside its edge. +/// +/// Each shadow uses the technique Chromium uses for inset shadows: clip to +/// the box, fill the plane minus a copy of the box that has been moved by the +/// shadow's offset and shrunk by its spread, then blur that ring. The border +/// paints last so it stays crisp over the shadows. +class InsetShadowDecoration extends Decoration { + const InsetShadowDecoration({ + this.color, + this.gradient, + this.borderRadius = BorderRadius.zero, + this.border, + this.boxShadows = const [], + this.shadows = const [], + }); + + final Color? color; + + /// Painted over [color] when set, so a gradient can sit on a base fill. + final Gradient? gradient; + final BorderRadius borderRadius; + final BoxBorder? border; + + /// Ordinary drop shadows, painted outside the box before the fill. + final List boxShadows; + final List shadows; + + @override + EdgeInsetsGeometry get padding => border?.dimensions ?? EdgeInsets.zero; + + @override + Path getClipPath(Rect rect, TextDirection textDirection) { + return Path()..addRRect(borderRadius.toRRect(rect)); + } + + @override + bool hitTest(Size size, Offset position, {TextDirection? textDirection}) { + return borderRadius.toRRect(Offset.zero & size).contains(position); + } + + @override + BoxPainter createBoxPainter([VoidCallback? onChanged]) { + return _InsetShadowPainter(this, onChanged); + } + + // Animated containers can tween to and from this decoration, including + // from a plain rounded [BoxDecoration], so a selected state can fade in. + @override + Decoration? lerpFrom(Decoration? a, double t) { + final from = _coerce(a); + return from == null ? null : _lerp(from, this, t); + } + + @override + Decoration? lerpTo(Decoration? b, double t) { + final to = _coerce(b); + return to == null ? null : _lerp(this, to, t); + } + + static InsetShadowDecoration? _coerce(Decoration? other) { + if (other == null) return const InsetShadowDecoration(); + if (other is InsetShadowDecoration) return other; + if (other is BoxDecoration && + other.shape == BoxShape.rectangle && + other.image == null && + other.backgroundBlendMode == null && + (other.borderRadius == null || other.borderRadius is BorderRadius) && + (other.border == null || other.border is Border)) { + return InsetShadowDecoration( + color: other.color, + gradient: other.gradient, + borderRadius: + (other.borderRadius as BorderRadius?) ?? BorderRadius.zero, + border: other.border, + boxShadows: other.boxShadow ?? const [], + ); + } + return null; + } + + static InsetShadowDecoration _lerp( + InsetShadowDecoration a, + InsetShadowDecoration b, + double t, + ) { + return InsetShadowDecoration( + color: Color.lerp(a.color, b.color, t), + gradient: Gradient.lerp(a.gradient, b.gradient, t), + borderRadius: BorderRadius.lerp(a.borderRadius, b.borderRadius, t)!, + border: BoxBorder.lerp(a.border, b.border, t), + boxShadows: BoxShadow.lerpList(a.boxShadows, b.boxShadows, t) ?? const [], + shadows: InsetShadow.lerpList(a.shadows, b.shadows, t), + ); + } +} + +class _InsetShadowPainter extends BoxPainter { + _InsetShadowPainter(this.decoration, super.onChanged); + + final InsetShadowDecoration decoration; + + @override + void paint(Canvas canvas, Offset offset, ImageConfiguration configuration) { + final size = configuration.size; + if (size == null) { + return; + } + final rect = offset & size; + final rrect = decoration.borderRadius.toRRect(rect); + + for (final shadow in decoration.boxShadows) { + canvas.drawRRect( + rrect.shift(shadow.offset).inflate(shadow.spreadRadius), + shadow.toPaint(), + ); + } + + final color = decoration.color; + if (color != null) { + canvas.drawRRect(rrect, Paint()..color = color); + } + final gradient = decoration.gradient; + if (gradient != null) { + canvas.drawRRect( + rrect, + Paint() + ..shader = gradient.createShader( + rect, + textDirection: configuration.textDirection, + ), + ); + } + + for (final shadow in decoration.shadows) { + _paintShadow(canvas, rect, rrect, shadow); + } + + decoration.border?.paint( + canvas, + rect, + textDirection: configuration.textDirection, + borderRadius: decoration.borderRadius, + ); + } + + void _paintShadow(Canvas canvas, Rect rect, RRect rrect, InsetShadow shadow) { + // The hole is the box itself, moved by the offset and shrunk by the + // spread; whatever it no longer covers inside the clip is the shadow. + final hole = rrect.shift(shadow.offset).deflate(shadow.spreadRadius); + // Extend the filled area past the clip so the blur never fades at the + // box edge, only at the hole edge. + final reach = shadow.blurRadius + shadow.spreadRadius; + final outer = rect.inflate(reach + shadow.offset.distance + 1); + + final ring = Path() + ..fillType = PathFillType.evenOdd + ..addRect(outer) + ..addRRect(hole); + + final paint = Paint()..color = shadow.color; + if (shadow.blurRadius > 0) { + paint.maskFilter = ui.MaskFilter.blur( + ui.BlurStyle.normal, + Shadow.convertRadiusToSigma(shadow.blurRadius), + ); + } + + canvas + ..save() + ..clipRRect(rrect) + ..drawPath(ring, paint) + ..restore(); + } +} diff --git a/lib/widgets/library_title_strip.dart b/lib/widgets/library_title_strip.dart index 7be4003f..31d01367 100644 --- a/lib/widgets/library_title_strip.dart +++ b/lib/widgets/library_title_strip.dart @@ -12,6 +12,8 @@ import 'package:shadcn_ui/shadcn_ui.dart'; const double _controlHeight = 28; // Tabs sit apart so a selected and a hovered background never touch. const double _tabGap = 4; +// Matches the ghost button's own radius so the rim hugs its hover shape. +const double _tabRadius = 6; // Action menus hug their labels. const double _sortMenuWidth = 168; const double _newMenuWidth = 140; @@ -280,19 +282,25 @@ class _TabButton extends StatelessWidget { onTap: onTap, child: Opacity( opacity: dimmed ? 0.45 : 1, - child: ShadButton.ghost( - height: _controlHeight, - padding: const EdgeInsets.symmetric(horizontal: 8), - gap: 6, - cursor: onTap == null ? SystemMouseCursors.basic : null, - backgroundColor: selected ? theme.secondary : null, - foregroundColor: foreground, - hoverForegroundColor: theme.foreground, - onPressed: onTap, - leading: Icon(icon, size: 15), - child: Text( - label, - style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500), + child: DecoratedBox( + decoration: selected + ? Settings.raisedSurface(_tabRadius) + : const BoxDecoration(), + child: ShadButton.ghost( + height: _controlHeight, + padding: const EdgeInsets.symmetric(horizontal: 8), + gap: 6, + cursor: onTap == null ? SystemMouseCursors.basic : null, + backgroundColor: selected ? Colors.transparent : null, + hoverBackgroundColor: selected ? Colors.transparent : null, + foregroundColor: foreground, + hoverForegroundColor: theme.foreground, + onPressed: onTap, + leading: Icon(icon, size: 15), + child: Text( + label, + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500), + ), ), ), ), diff --git a/lib/widgets/line_up_placer.dart b/lib/widgets/line_up_placer.dart index 6257e6d1..e4a272a4 100644 --- a/lib/widgets/line_up_placer.dart +++ b/lib/widgets/line_up_placer.dart @@ -164,14 +164,7 @@ class _LineupPositionWidgetState extends ConsumerState { horizontal: 12, vertical: 8, ), - decoration: BoxDecoration( - color: Settings.tacticalVioletTheme.primary, - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: Settings.tacticalVioletTheme.border, - ), - boxShadow: const [Settings.cardForegroundBackdrop], - ), + decoration: Settings.raisedPrimary(8), child: Text( lineUpPlacementStatus(placement), style: ShadTheme.of(context) diff --git a/lib/widgets/map_selector.dart b/lib/widgets/map_selector.dart index fe179a82..237e19ca 100644 --- a/lib/widgets/map_selector.dart +++ b/lib/widgets/map_selector.dart @@ -16,12 +16,20 @@ class MapSelector extends ConsumerStatefulWidget { } class _MapSelectorState extends ConsumerState { - static const double _cardWidth = 262; static const double _cardHeight = 65; static const double _outerRadius = 12; static const double _innerGap = 4; static const double _innerRadius = _outerRadius - _innerGap; + static const double _borderWidth = 1; 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 + + 2 * _innerGap + + MapTile.width + + _innerGap + + _sideToggleWidth; final OverlayPortalController _controller = OverlayPortalController(); final _link = LayerLink(); @@ -56,19 +64,20 @@ 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(Maps.mapNames[b]!.toLowerCase()), - ); + 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()), + (a, b) => Maps.mapNames[a]!.toLowerCase().compareTo( + Maps.mapNames[b]!.toLowerCase(), + ), ); return CompositedTransformTarget( @@ -77,7 +86,10 @@ class _MapSelectorState extends ConsumerState { decoration: BoxDecoration( color: Settings.tacticalVioletTheme.card, borderRadius: const BorderRadius.all(Radius.circular(_outerRadius)), - border: Border.all(color: Settings.tacticalVioletTheme.border), + border: Border.all( + color: Settings.tacticalVioletTheme.border, + width: _borderWidth, + ), ), width: _cardWidth, height: _cardHeight, @@ -101,8 +113,9 @@ class _MapSelectorState extends ConsumerState { width: 260, decoration: BoxDecoration( color: Settings.tacticalVioletTheme.card, - borderRadius: - const BorderRadius.all(Radius.circular(10)), + borderRadius: const BorderRadius.all( + Radius.circular(10), + ), border: Border.all( color: Settings.tacticalVioletTheme.border, width: 2, @@ -110,15 +123,17 @@ class _MapSelectorState extends ConsumerState { ), child: ClipRRect( clipBehavior: Clip.antiAlias, - borderRadius: - const BorderRadius.all(Radius.circular(10)), + borderRadius: const BorderRadius.all( + Radius.circular(10), + ), child: Column( crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded( child: ListView.separated( padding: const EdgeInsets.all(_innerGap), - itemCount: availableMaps.length + + itemCount: + availableMaps.length + outOfRotationMaps.length + 1, separatorBuilder: (_, __) => @@ -154,15 +169,21 @@ class _MapSelectorState extends ConsumerState { style: TextStyle( fontWeight: FontWeight.w500, color: Color.fromARGB( - 255, 160, 160, 160), + 255, + 160, + 160, + 160, + ), ), textAlign: TextAlign.center, ), ); } - final mapValue = outOfRotationMaps[ - index - availableMaps.length - 1]; + final mapValue = + outOfRotationMaps[index - + availableMaps.length - + 1]; final mapName = Maps.mapNames[mapValue]!; return MapTile( name: mapName, @@ -235,12 +256,12 @@ class _MapSelectorState extends ConsumerState { fontWeight: FontWeight.w500, color: Colors.white, ), - ) + ), ], ), ), ), - ) + ), ], ), ), diff --git a/lib/widgets/map_tile.dart b/lib/widgets/map_tile.dart index fc1d9e83..61d2851a 100644 --- a/lib/widgets/map_tile.dart +++ b/lib/widgets/map_tile.dart @@ -18,6 +18,10 @@ class MapTile extends ConsumerStatefulWidget { final bool isActive; final double borderRadius; + /// The tile's fixed footprint; the map card sizes itself from this. + static const double width = 180; + static const double height = 65; + @override ConsumerState createState() => _MapTileState(); } @@ -28,18 +32,20 @@ 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( mouseCursor: SystemMouseCursors.click, onTap: widget.onTap, child: SizedBox( - width: 180, - height: 65, + width: MapTile.width, + height: MapTile.height, child: Stack( children: [ Positioned.fill( @@ -77,11 +83,11 @@ class _MapTileState extends ConsumerState { color: Colors.black, blurRadius: 2, offset: Offset(0, 2), - ) + ), ], ), ), - ) + ), ], ), ), diff --git a/lib/widgets/pages_bar.dart b/lib/widgets/pages_bar.dart index f5e561cc..078ce29d 100644 --- a/lib/widgets/pages_bar.dart +++ b/lib/widgets/pages_bar.dart @@ -411,7 +411,7 @@ class _CollapsedPill extends StatelessWidget { overflow: TextOverflow.ellipsis, style: theme.textTheme.titleMedium?.copyWith( color: Colors.white, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w500, fontSize: 14), ), ), @@ -786,9 +786,7 @@ class _PageRowState extends State<_PageRow> { final fillProgress = widget.transitionProgress?.clamp(0.0, 1.0); final showActions = _hovered || widget.active || widget.transitionProgress != null; - final bg = widget.active && fillProgress == null - ? Settings.tacticalVioletTheme.primary - : Settings.tacticalVioletTheme.card; + final isRaised = widget.active && fillProgress == null; return MouseRegion( onEnter: (_) => setState(() => _hovered = true), onExit: (_) => setState(() => _hovered = false), @@ -803,34 +801,48 @@ class _PageRowState extends State<_PageRow> { onTap: () => widget.onSelect(widget.page.id), child: ClipRRect( borderRadius: BorderRadius.circular(_PageRow._rowRadius), - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(_PageRow._rowRadius), - border: Border.all( - color: Settings.tacticalVioletTheme.border, - width: 1, - ), - boxShadow: [ - BoxShadow( - color: Settings.tacticalVioletTheme.card - .withValues(alpha: 0.2), - blurRadius: 12, - offset: const Offset(0, 4)) - ], - color: bg, - ), + // Every layer is a Positioned.fill over one fixed-size box, so + // the surface, the transition sweep, and the content share the + // same rect in every state. (A Container would inset its child + // by the resting border's width and shift everything 1px when + // the row turns active, which has no border.) + child: SizedBox( height: _PageRow._rowHeight, child: Stack( children: [ + Positioned.fill( + child: DecoratedBox( + decoration: isRaised + ? Settings.raisedPrimary(_PageRow._rowRadius) + : BoxDecoration( + borderRadius: + BorderRadius.circular(_PageRow._rowRadius), + border: Border.all( + color: Settings.tacticalVioletTheme.border, + width: 1, + ), + boxShadow: [ + BoxShadow( + color: Settings.tacticalVioletTheme.card + .withValues(alpha: 0.2), + blurRadius: 12, + offset: const Offset(0, 4)) + ], + color: Settings.tacticalVioletTheme.card, + ), + ), + ), if (fillProgress != null) Positioned.fill( child: FractionallySizedBox( widthFactor: fillProgress, alignment: Alignment.centerLeft, + // The same surface as the active row, so the + // sweep's corners match at every width and it + // lands exactly on the active state. child: DecoratedBox( - decoration: BoxDecoration( - color: Settings.tacticalVioletTheme.primary, - ), + decoration: + Settings.raisedPrimary(_PageRow._rowRadius), ), ), ), @@ -845,9 +857,7 @@ class _PageRowState extends State<_PageRow> { overflow: TextOverflow.ellipsis, style: theme.textTheme.titleMedium?.copyWith( color: Colors.white, - fontWeight: widget.active - ? FontWeight.w600 - : FontWeight.w500, + fontWeight: FontWeight.w500, fontSize: 14, ), ), diff --git a/lib/widgets/selectable_icon_button.dart b/lib/widgets/selectable_icon_button.dart index b76ad8c3..e320018b 100644 --- a/lib/widgets/selectable_icon_button.dart +++ b/lib/widgets/selectable_icon_button.dart @@ -3,6 +3,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/settings.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; +// The secondary icon button's own corner radius (the theme default). +const double _radius = 6; + class SelectableIconButton extends ConsumerWidget { const SelectableIconButton({ super.key, @@ -24,17 +27,24 @@ 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; Widget button = ShadIconButton.secondary( padding: EdgeInsets.zero, icon: icon, - backgroundColor: isSelected - ? hoverBackgroundColor ?? Settings.tacticalVioletTheme.primary - : null, - hoverBackgroundColor: isSelected - ? hoverBackgroundColor ?? Settings.tacticalVioletTheme.primary - : null, + backgroundColor: flatColor, + hoverBackgroundColor: flatColor, onPressed: onPressed, ); + if (raised) { + button = DecoratedBox( + decoration: Settings.raisedPrimary(_radius), + child: button, + ); + } // No tooltip text means no ShadTooltip wrapper; an empty tooltip bubble // would still pop up on hover otherwise. diff --git a/lib/widgets/strategy_quick_switcher.dart b/lib/widgets/strategy_quick_switcher.dart index ffba409c..049a5907 100644 --- a/lib/widgets/strategy_quick_switcher.dart +++ b/lib/widgets/strategy_quick_switcher.dart @@ -6,8 +6,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:hive_ce_flutter/adapters.dart'; import 'package:icarus/const/hive_boxes.dart'; import 'package:icarus/const/maps.dart'; -import 'package:icarus/const/settings.dart'; import 'package:icarus/const/shortcut_info.dart'; +import 'package:icarus/const/settings.dart'; import 'package:icarus/providers/agent_filter_provider.dart'; import 'package:icarus/providers/interaction_state_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; @@ -28,6 +28,12 @@ class StrategyQuickSwitcher extends ConsumerStatefulWidget { class _StrategyQuickSwitcherState extends ConsumerState { static const double _barWidth = 280; static const double _barHeight = 30; + static const double _barRadius = 8; + static const double _chevronWidth = 38; + // Menu geometry, matching the library strip's popovers. + static const double _menuRadius = 12; + static const double _menuInset = 6; + static const double _rowInset = 6; static const EdgeInsets _displayMargin = EdgeInsets.symmetric(horizontal: 16); final OverlayPortalController _controller = OverlayPortalController(); final LayerLink _layerLink = LayerLink(); @@ -249,6 +255,7 @@ class _StrategyQuickSwitcherState extends ConsumerState { @override Widget build(BuildContext context) { final currentStrategy = ref.watch(strategyProvider); + final currentStrategyId = currentStrategy.id; final strategyName = currentStrategy.stratName ?? 'Untitled Strategy'; final strategiesBox = Hive.box(HiveBoxNames.strategiesBox); @@ -261,7 +268,7 @@ class _StrategyQuickSwitcherState extends ConsumerState { builder: (context, box, _) { final recents = _recentStrategies( box: box, - currentStrategyId: currentStrategy.id, + currentStrategyId: currentStrategyId, ); return OverlayPortal.overlayChildLayoutBuilder( @@ -293,73 +300,81 @@ class _StrategyQuickSwitcherState extends ConsumerState { left: left, top: top, width: _barWidth, - child: Material( - color: Colors.transparent, - child: Container( - constraints: BoxConstraints(maxHeight: maxHeight), - decoration: BoxDecoration( - color: Settings.tacticalVioletTheme.background, - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: Settings.tacticalVioletTheme.border, - ), + // A floating menu like the library strip's: popover + // grey, hairline border, panel radius, one shadow. The + // rows inside are flat. + child: Container( + constraints: BoxConstraints(maxHeight: maxHeight), + decoration: BoxDecoration( + color: Settings.tacticalVioletTheme.popover, + borderRadius: BorderRadius.circular(_menuRadius), + border: Border.all( + color: Settings.tacticalVioletTheme.border, ), - child: recents.isEmpty - ? Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 10, - ), - child: Text( - 'No recent strategies', - style: ShadTheme.of(context) - .textTheme - .small - .copyWith(color: Colors.white70), + boxShadow: const [Settings.floatingMenuShadow], + ), + child: recents.isEmpty + ? Padding( + padding: const EdgeInsets.fromLTRB( + _menuInset + _rowInset, + _menuInset + 6, + _menuInset + _rowInset, + _menuInset + 6, + ), + child: Text( + 'No recent strategies', + style: TextStyle( + fontSize: 12, + color: Settings + .tacticalVioletTheme.mutedForeground, ), - ) - : ListView.separated( - shrinkWrap: true, - padding: const EdgeInsets.all(8), - itemCount: recents.length, - separatorBuilder: (_, __) => - const SizedBox(height: 8), - itemBuilder: (context, index) { - final strategy = recents[index]; - final attackLabel = _attackLabel(strategy); - final mapName = _mapName(strategy); - final thumbnail = - 'assets/maps/thumbnails/${Maps.mapNames[strategy.mapData]}_thumbnail.webp'; - return _StrategyQuickSwitchItem( - strategyName: strategy.name, - mapName: mapName, - attackLabel: attackLabel, - attackColor: _attackColor(attackLabel), - lastEdited: _timeAgo(strategy.lastEdited), - thumbnailPath: thumbnail, - onTap: _isSwitching || _isEditingName - ? null - : () => _switchStrategy(strategy.id), - ); - }, ), - ), + ) + : ListView.separated( + shrinkWrap: true, + padding: const EdgeInsets.all(_menuInset), + itemCount: recents.length, + separatorBuilder: (_, __) => + const SizedBox(height: 2), + itemBuilder: (context, index) { + final strategy = recents[index]; + final attackLabel = _attackLabel(strategy); + final mapName = _mapName(strategy); + final thumbnail = + 'assets/maps/thumbnails/${Maps.mapNames[strategy.mapData]}_thumbnail.webp'; + return _StrategyQuickSwitchItem( + strategyName: strategy.name, + mapName: mapName, + attackLabel: attackLabel, + attackColor: _attackColor(attackLabel), + lastEdited: _timeAgo(strategy.lastEdited), + thumbnailPath: thumbnail, + onTap: _isSwitching || _isEditingName + ? null + : () => _switchStrategy(strategy.id), + ); + }, + ), ), ), ], ); }, + // A two-segment control. The children are clipped to the + // bar's inner rounded rect, so each segment's hover fill runs + // edge to edge and the bar's own corners round it off. child: Container( key: const ValueKey('strategy-quick-switcher-control'), width: _barWidth, height: _barHeight, decoration: BoxDecoration( color: Settings.tacticalVioletTheme.card, - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(_barRadius), border: Border.all( color: Settings.tacticalVioletTheme.border, ), ), + clipBehavior: Clip.antiAlias, child: Row( children: [ Expanded( @@ -441,7 +456,8 @@ class _StrategyQuickSwitcherState extends ConsumerState { mouseCursor: currentStrategy.stratName == null ? SystemMouseCursors.basic : SystemMouseCursors.click, - borderRadius: BorderRadius.circular(8), + hoverColor: + Settings.tacticalVioletTheme.accent, child: Center( child: Padding( padding: const EdgeInsets.symmetric( @@ -465,12 +481,17 @@ class _StrategyQuickSwitcherState extends ConsumerState { ), Container( width: 1, - height: 30, color: Settings.tacticalVioletTheme.border, ), SizedBox( - width: 38, + width: _chevronWidth, child: ShadIconButton.ghost( + width: _chevronWidth, + height: double.infinity, + padding: EdgeInsets.zero, + decoration: const ShadDecoration( + border: ShadBorder(radius: BorderRadius.zero), + ), onPressed: _isSwitching || _isEditingName ? null : () => _isOpen ? _closePortal() : _openPortal(), @@ -526,106 +547,84 @@ class _StrategyQuickSwitchItem extends StatefulWidget { } class _StrategyQuickSwitchItemState extends State<_StrategyQuickSwitchItem> { - bool _isHovered = false; + static const double _rowHeight = 40; + static const double _thumbnail = 28; @override Widget build(BuildContext context) { - final isEnabled = widget.onTap != null; - final borderColor = _isHovered - ? Settings.tacticalVioletTheme.primary - : Settings.tacticalVioletTheme.border; - final backgroundColor = _isHovered - ? Settings.tacticalVioletTheme.card.withValues(alpha: 0.85) - : Settings.tacticalVioletTheme.card; - - return MouseRegion( - cursor: isEnabled ? SystemMouseCursors.click : SystemMouseCursors.basic, - onEnter: (_) => setState(() => _isHovered = true), - onExit: (_) => setState(() => _isHovered = false), - child: AnimatedContainer( - duration: const Duration(milliseconds: 120), - curve: Curves.easeOut, - decoration: BoxDecoration( - color: backgroundColor, - borderRadius: BorderRadius.circular(10), - border: Border.all(color: borderColor), - boxShadow: const [Settings.cardForegroundBackdrop], + const theme = Settings.tacticalVioletTheme; + // A flat menu row: thumbnail, name over map, side over time. Hover is + // the ghost button's grey fill; nothing here is bordered or shadowed. + return ShadButton.ghost( + height: _rowHeight, + expands: true, + mainAxisAlignment: MainAxisAlignment.start, + padding: const EdgeInsets.symmetric( + horizontal: _StrategyQuickSwitcherState._rowInset, + ), + gap: 10, + onPressed: widget.onTap, + leading: ClipRRect( + borderRadius: BorderRadius.circular(4), + child: Image.asset( + widget.thumbnailPath, + width: _thumbnail, + height: _thumbnail, + fit: BoxFit.cover, ), - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: widget.onTap, - borderRadius: BorderRadius.circular(10), - mouseCursor: - isEnabled ? SystemMouseCursors.click : SystemMouseCursors.basic, - hoverColor: - Settings.tacticalVioletTheme.primary.withValues(alpha: 0.12), - splashColor: - Settings.tacticalVioletTheme.primary.withValues(alpha: 0.2), - child: Padding( - padding: const EdgeInsets.all(6), - child: Row( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Image.asset( - widget.thumbnailPath, - width: 46, - height: 46, - fit: BoxFit.cover, - ), - ), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - OverflowTooltipText( - widget.strategyName, - style: ShadTheme.of(context).textTheme.small.copyWith( - color: Colors.white, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 2), - Text( - widget.mapName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: ShadTheme.of(context).textTheme.small.copyWith( - color: Colors.white70, - ), - ), - ], - ), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + OverflowTooltipText( + widget.strategyName, + style: TextStyle( + fontSize: 13, + height: 1.2, + color: theme.foreground, ), - const SizedBox(width: 10), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - widget.attackLabel, - style: ShadTheme.of(context).textTheme.small.copyWith( - color: widget.attackColor, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: 2), - Text( - widget.lastEdited, - style: ShadTheme.of(context).textTheme.small.copyWith( - color: Colors.white54, - ), - ), - ], + ), + Text( + widget.mapName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12, + height: 1.2, + color: theme.mutedForeground, ), - ], - ), + ), + ], ), ), - ), + const SizedBox(width: 10), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + widget.attackLabel, + style: TextStyle( + fontSize: 12, + height: 1.2, + color: widget.attackColor, + ), + ), + Text( + widget.lastEdited, + style: TextStyle( + fontSize: 12, + height: 1.2, + color: theme.mutedForeground, + ), + ), + ], + ), + ], ), ); } diff --git a/lib/widgets/strategy_save_icon_button.dart b/lib/widgets/strategy_save_icon_button.dart index 63a6d3c2..78c634eb 100644 --- a/lib/widgets/strategy_save_icon_button.dart +++ b/lib/widgets/strategy_save_icon_button.dart @@ -31,10 +31,9 @@ Future saveStrategyNow(BuildContext context, WidgetRef ref) async { ), child: Text( 'Save Complete', - style: ShadTheme.of(context) - .textTheme - .small - .copyWith(color: Settings.tacticalVioletTheme.foreground), + style: ShadTheme.of(context).textTheme.small.copyWith( + color: Settings.tacticalVioletTheme.foreground, + ), ), ); }, @@ -44,10 +43,7 @@ Future saveStrategyNow(BuildContext context, WidgetRef ref) async { /// The save button of the open strategy. Shows a spinner while an auto-save /// runs and a check when it lands, then rests on the save glyph. class AutoSaveButton extends ConsumerStatefulWidget { - const AutoSaveButton({ - super.key, - this.style = kEditorToolbarButtonStyle, - }); + const AutoSaveButton({super.key, this.style = kEditorToolbarButtonStyle}); final EditorToolbarButtonStyle style; @@ -100,23 +96,23 @@ class _AutoSaveButtonState extends ConsumerState { final size = widget.style.iconSize; final Widget icon = switch (_phase) { - _Phase.idle => const Icon(LucideIcons.save300, key: ValueKey('idle')), + _Phase.idle => const Icon(LucideIcons.save200, key: ValueKey('idle')), _Phase.loading => SizedBox( - key: const ValueKey('loading'), - width: size - 2, - height: size - 2, - child: CircularProgressIndicator( - strokeWidth: 1.8, - valueColor: AlwaysStoppedAnimation( - Settings.tacticalVioletTheme.mutedForeground, - ), + key: const ValueKey('loading'), + width: size - 2, + height: size - 2, + child: CircularProgressIndicator( + strokeWidth: 1.8, + valueColor: AlwaysStoppedAnimation( + Settings.tacticalVioletTheme.mutedForeground, ), ), + ), _Phase.success => const Icon( - LucideIcons.check300, - key: ValueKey('success'), - color: Settings.allyBGColor, - ), + LucideIcons.check200, + key: ValueKey('success'), + color: Settings.allyBGColor, + ), }; return EditorToolbarButton( diff --git a/lib/widgets/strategy_tile/strategy_tile.dart b/lib/widgets/strategy_tile/strategy_tile.dart index 965baeea..9f667f04 100644 --- a/lib/widgets/strategy_tile/strategy_tile.dart +++ b/lib/widgets/strategy_tile/strategy_tile.dart @@ -33,8 +33,10 @@ class StrategyTile extends ConsumerStatefulWidget { ConsumerState createState() => _StrategyTileState(); } +const double _ringWidth = 2; + class _StrategyTileState extends ConsumerState { - Color _highlightColor = Settings.tacticalVioletTheme.border; + bool _isHovered = false; bool _isLoading = false; bool _menuButtonWasOpenOnPointerDown = false; DropInsertionSide? _pinnedDropSide; @@ -153,10 +155,8 @@ class _StrategyTileState extends ConsumerState { ), child: MouseRegion( cursor: SystemMouseCursors.click, - onEnter: (_) => setState( - () => _highlightColor = Settings.tacticalVioletTheme.ring), - onExit: (_) => setState( - () => _highlightColor = Settings.tacticalVioletTheme.border), + onEnter: (_) => setState(() => _isHovered = true), + onExit: (_) => setState(() => _isHovered = false), child: AbsorbPointer( absorbing: _isLoading, child: ShadContextMenuRegion( @@ -177,38 +177,47 @@ class _StrategyTileState extends ConsumerState { return Stack( clipBehavior: Clip.none, children: [ + // The ring is the outer box showing through a + // 2px inset: flat zinc at rest, and on hover the + // lit violet gradient, since a Border cannot + // take a gradient. AnimatedContainer( - duration: const Duration(milliseconds: 100), - decoration: BoxDecoration( - color: ShadTheme.of(context).colorScheme.card, - borderRadius: BorderRadius.circular( - strategyTileOuterRadius), - border: Border.all( - color: isPinDropTarget - ? Settings.tacticalVioletTheme.border - : _highlightColor, - width: 2, + duration: const Duration(milliseconds: 100), + decoration: BoxDecoration( + color: Settings.tacticalVioletTheme.border, + gradient: !isPinDropTarget && _isHovered + ? Settings.raisedPrimaryFill + : null, + borderRadius: BorderRadius.circular( + strategyTileOuterRadius), ), - ), - padding: const EdgeInsets.all(8), - child: Column( - children: [ - Expanded( - child: StrategyTileThumbnail( - assetPath: viewData.thumbnailAsset, - borderRadius: strategyTileInnerRadius, - ), + padding: const EdgeInsets.all(_ringWidth), + child: Container( + decoration: BoxDecoration( + color: + ShadTheme.of(context).colorScheme.card, + borderRadius: BorderRadius.circular( + strategyTileOuterRadius - _ringWidth), ), - const SizedBox(height: 10), - Expanded( - child: StrategyTileDetails( - data: viewData, - borderRadius: strategyTileInnerRadius, - ), + padding: const EdgeInsets.all(8 - _ringWidth), + child: Column( + children: [ + Expanded( + child: StrategyTileThumbnail( + assetPath: viewData.thumbnailAsset, + borderRadius: strategyTileInnerRadius, + ), + ), + const SizedBox(height: 10), + Expanded( + child: StrategyTileDetails( + data: viewData, + borderRadius: strategyTileInnerRadius, + ), + ), + ], ), - ], - ), - ), + )), if (isPinned) Align( alignment: Alignment.topLeft, diff --git a/test/sunset_scale_migration_test.dart b/test/sunset_scale_migration_test.dart index 84123fb2..6c41890c 100644 --- a/test/sunset_scale_migration_test.dart +++ b/test/sunset_scale_migration_test.dart @@ -227,7 +227,7 @@ void main() { map: MapValue.sunset, ); const shift = 30 * (5.5 * 1.048 - 5.78 * _oldScale); - _expectPoint(migrated.position, Offset(shift, shift)); + _expectPoint(migrated.position, const Offset(shift, shift)); final source = _page(true); final oldCircle = source.utilityData.singleWhere( (u) => u.type == UtilityType.customCircle, @@ -240,7 +240,8 @@ void main() { (u) => u.type == UtilityType.customCircle, ); const inset = (40 - 14) * 5.78 * _oldScale; - _expectPoint(circle.position, oldCircle.position - Offset(inset, inset)); + _expectPoint( + circle.position, oldCircle.position - const Offset(inset, inset)); }); for (final version in [16, 38, 39, 44, 45, 96]) { diff --git a/test/unsaved_strategy_guard_test.dart b/test/unsaved_strategy_guard_test.dart index 10dbdc7d..5cf242dc 100644 --- a/test/unsaved_strategy_guard_test.dart +++ b/test/unsaved_strategy_guard_test.dart @@ -411,7 +411,7 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Save changes?'), findsOneWidget); - await tester.tap(find.text("Don't Save")); + await tester.tap(find.text("Don't save")); await tester.pumpAndSettle(); final result = await guardFuture; diff --git a/test/widgets/inset_shadow_decoration_test.dart b/test/widgets/inset_shadow_decoration_test.dart new file mode 100644 index 00000000..b606a33a --- /dev/null +++ b/test/widgets/inset_shadow_decoration_test.dart @@ -0,0 +1,155 @@ +import 'dart:io'; +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:icarus/widgets/inset_shadow_decoration.dart'; + +const _size = Size(120, 40); +const _fill = Color(0xff27272a); + +Future<_Pixels> _render(WidgetTester tester, Decoration decoration) async { + final key = GlobalKey(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: RepaintBoundary( + key: key, + child: SizedBox( + width: _size.width, + height: _size.height + 1, + child: Align( + alignment: Alignment.topCenter, + child: Container( + width: _size.width, + height: _size.height, + decoration: decoration, + ), + ), + ), + ), + ), + ), + ); + final boundary = + key.currentContext!.findRenderObject()! as RenderRepaintBoundary; + // Engine futures only resolve inside runAsync; the fake-async test zone + // never completes them. + late _Pixels pixels; + await tester.runAsync(() async { + final image = await boundary.toImage(); + final dump = Platform.environment['INSET_SHADOW_DUMP']; + if (dump != null) { + final png = await image.toByteData(format: ui.ImageByteFormat.png); + File(dump).writeAsBytesSync(png!.buffer.asUint8List()); + } + pixels = _Pixels(image.width, (await image.toByteData())!); + }); + return pixels; +} + +class _Pixels { + const _Pixels(this.width, this.bytes); + + final int width; + final ByteData bytes; + + Color at(int x, int y) { + final i = (y * width + x) * 4; + return Color.fromARGB( + bytes.getUint8(i + 3), + bytes.getUint8(i), + bytes.getUint8(i + 1), + bytes.getUint8(i + 2), + ); + } +} + +void main() { + testWidgets('raised rim lightens the edges and the top edge most', + (tester) async { + final image = await _render( + tester, + InsetShadowDecoration( + color: _fill, + borderRadius: BorderRadius.circular(6), + shadows: Settings.raisedRim, + ), + ); + final centre = image.at(60, 20); + final top = image.at(60, 0); + final bottom = image.at(60, 39); + final left = image.at(0, 20); + final inside = image.at(60, 2); + + expect(centre, _fill, reason: 'the face keeps the plain fill'); + expect(inside, _fill, reason: 'the edges are one pixel deep'); + expect(left, _fill, reason: 'the sides stay bare'); + expect(top.r, greaterThan(centre.r), reason: 'top edge is lit'); + expect(bottom.r, lessThan(centre.r), reason: 'bottom edge is shaded'); + }); + + testWidgets('gradient paints over the fill and drop shadows fall outside', + (tester) async { + final image = await _render( + tester, + InsetShadowDecoration( + gradient: Settings.raisedGradient(_fill), + borderRadius: BorderRadius.circular(6), + boxShadows: const [Settings.raisedDropShadow], + ), + ); + expect(image.at(60, 4).r, greaterThan(image.at(60, 35).r), + reason: 'the fill runs lighter at the top'); + expect(image.at(60, 40).a, greaterThan(0), + reason: 'the shadow lands one pixel under the box'); + }); + + testWidgets('a blurred inset shadow darkens toward the edge', + (tester) async { + final image = await _render( + tester, + const InsetShadowDecoration( + color: Color(0xffffffff), + shadows: [ + InsetShadow(color: Color(0xff000000), blurRadius: 8), + ], + ), + ); + final centre = image.at(60, 20); + final nearEdge = image.at(60, 1); + final midway = image.at(60, 4); + expect(centre, const Color(0xffffffff)); + expect(nearEdge.r, lessThan(midway.r)); + expect(midway.r, lessThan(centre.r)); + }); + + testWidgets('without shadows nothing paints outside the fill', + (tester) async { + final image = await _render( + tester, + const InsetShadowDecoration(color: _fill), + ); + expect(image.at(0, 0), _fill); + expect(image.at(119, 39), _fill); + }); + + test('tweens from a plain rounded BoxDecoration', () { + final from = BoxDecoration( + color: _fill, + borderRadius: BorderRadius.circular(6), + ); + final to = Settings.raisedPrimary(6); + final mid = Decoration.lerp(from, to, 0.5); + expect(mid, isA()); + final raised = mid! as InsetShadowDecoration; + expect(raised.shadows, hasLength(2)); + expect(raised.shadows.first.color.a, closeTo(0.07, 0.01), + reason: 'the top light fades in halfway'); + expect(Decoration.lerp(to, from, 0.75), isA()); + }); +}