diff --git a/docs/motion-and-high-refresh.md b/docs/motion-and-high-refresh.md index 0204610a..dfd24006 100644 --- a/docs/motion-and-high-refresh.md +++ b/docs/motion-and-high-refresh.md @@ -177,7 +177,7 @@ When reviewing PRs that touch animation: | Constant | Value | Where | |----------|-------|--------| | `kQueryaStaggerStep` | 30 ms | `QueryaStagger` first-paint choreography | -| `kUpdateBadgePulsePeriod` | 1400 ms | Update title-bar chip pulse (chrome; see #363) | +| `kUpdateBadgePulsePeriod` | 1400 ms | Update title-bar chip pulse at Full; Reduced halves via `effectiveDuration`; Off / OS disable stop (#363, #482) | Checklist for 120 Hz verification: [perf-baseline.md](perf-baseline.md) ยง Fluid shell. diff --git a/lib/core/motion/querya_hover_surface.dart b/lib/core/motion/querya_hover_surface.dart index 40c3b1c1..8db6875e 100644 --- a/lib/core/motion/querya_hover_surface.dart +++ b/lib/core/motion/querya_hover_surface.dart @@ -9,6 +9,7 @@ class QueryaHoverSurface extends StatefulWidget { super.key, required this.child, this.borderRadius, + this.border, this.padding, this.hoveredColor, this.idleColor = Colors.transparent, @@ -18,6 +19,7 @@ class QueryaHoverSurface extends StatefulWidget { final Widget child; final BorderRadius? borderRadius; + final BoxBorder? border; final EdgeInsetsGeometry? padding; final Color? hoveredColor; final Color idleColor; @@ -46,6 +48,7 @@ class _QueryaHoverSurfaceState extends State { decoration: BoxDecoration( color: _hovered ? hovered : widget.idleColor, borderRadius: widget.borderRadius, + border: widget.border, ), child: widget.child, ); diff --git a/lib/features/connections/new_connection_dialog.dart b/lib/features/connections/new_connection_dialog.dart index 25995f45..dc26a23f 100644 --- a/lib/features/connections/new_connection_dialog.dart +++ b/lib/features/connections/new_connection_dialog.dart @@ -5,8 +5,7 @@ import 'package:querya_desktop/core/ui/querya_icons.dart'; import 'package:querya_desktop/core/extensions/extension_driver_catalog.dart'; import 'package:querya_desktop/core/extensions/local_extension_registry.dart'; import 'package:querya_desktop/core/layout/window_layout.dart'; -import 'package:querya_desktop/core/motion/querya_motion.dart'; -import 'package:querya_desktop/core/motion/querya_motion_context.dart'; +import 'package:querya_desktop/core/motion/querya_hover_surface.dart'; import 'package:querya_desktop/features/connections/connection_type_choice.dart'; import 'package:querya_desktop/features/connections/driver_icon.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -385,7 +384,7 @@ class _FilterDropdowns extends StatelessWidget { } } -class _DbTypeCard extends material.StatefulWidget { +class _DbTypeCard extends material.StatelessWidget { const _DbTypeCard({ required this.choice, required this.theme, @@ -398,89 +397,70 @@ class _DbTypeCard extends material.StatefulWidget { final bool selected; final VoidCallback onTap; - @override - material.State<_DbTypeCard> createState() => _DbTypeCardState(); -} - -class _DbTypeCardState extends material.State<_DbTypeCard> { - bool _hovered = false; - @override material.Widget build(material.BuildContext context) { - final t = widget.theme; - final highlighted = widget.selected || _hovered; - return material.MouseRegion( - onEnter: (_) => setState(() => _hovered = true), - onExit: (_) => setState(() => _hovered = false), - cursor: material.SystemMouseCursors.click, - child: material.GestureDetector( - onTap: widget.onTap, - child: material.AnimatedContainer( - duration: context.motionDuration(QueryaMotion.fast), - curve: context.motionCurve(QueryaMotion.enter), - padding: - const material.EdgeInsets.symmetric(vertical: 10, horizontal: 8), - decoration: material.BoxDecoration( - color: highlighted - ? t.muted.withValues(alpha: 0.4) - : t.muted.withValues(alpha: 0.12), - borderRadius: material.BorderRadius.circular(10), - border: material.Border.all( - color: widget.selected - ? t.primary.withValues(alpha: 0.6) - : t.border.withValues(alpha: 0.35), - width: widget.selected ? 1.5 : 1, + final t = theme; + final highlight = t.muted.withValues(alpha: 0.4); + return QueryaHoverSurface( + borderRadius: material.BorderRadius.circular(10), + padding: + const material.EdgeInsets.symmetric(vertical: 10, horizontal: 8), + idleColor: selected ? highlight : t.muted.withValues(alpha: 0.12), + hoveredColor: highlight, + border: material.Border.all( + color: selected + ? t.primary.withValues(alpha: 0.6) + : t.border.withValues(alpha: 0.35), + width: selected ? 1.5 : 1, + ), + onTap: onTap, + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Expanded( + child: material.Center( + child: material.SizedBox( + width: 52, + height: 52, + child: DriverIcon( + filePath: choice.iconFile, + assetPath: choice.iconAsset, + size: 52, + fallbackIcon: choice.icon, + ), + ), ), ), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - material.Expanded( - child: material.Center( - child: material.SizedBox( - width: 52, - height: 52, - child: DriverIcon( - filePath: widget.choice.iconFile, - assetPath: widget.choice.iconAsset, - size: 52, - fallbackIcon: widget.choice.icon, + const material.SizedBox(height: 6), + material.LayoutBuilder( + builder: (context, lc) { + return material.SizedBox( + height: 38, + child: material.FittedBox( + fit: material.BoxFit.scaleDown, + alignment: material.Alignment.center, + child: material.ConstrainedBox( + constraints: material.BoxConstraints( + maxWidth: math.max(48.0, lc.maxWidth), ), - ), - ), - ), - const material.SizedBox(height: 6), - material.LayoutBuilder( - builder: (context, lc) { - return material.SizedBox( - height: 38, - child: material.FittedBox( - fit: material.BoxFit.scaleDown, - alignment: material.Alignment.center, - child: material.ConstrainedBox( - constraints: material.BoxConstraints( - maxWidth: math.max(48.0, lc.maxWidth), - ), - child: material.Text( - widget.choice.label, - textAlign: material.TextAlign.center, - maxLines: 2, - overflow: material.TextOverflow.ellipsis, - style: material.TextStyle( - fontSize: 13, - fontWeight: material.FontWeight.w600, - height: 1.2, - color: t.foreground, - ), - ), + child: material.Text( + choice.label, + textAlign: material.TextAlign.center, + maxLines: 2, + overflow: material.TextOverflow.ellipsis, + style: material.TextStyle( + fontSize: 13, + fontWeight: material.FontWeight.w600, + height: 1.2, + color: t.foreground, ), ), - ); - }, - ), - ], + ), + ), + ); + }, ), - ), + ], ), ); } diff --git a/lib/features/updater/update_available_badge.dart b/lib/features/updater/update_available_badge.dart index 72384c19..832de9b8 100644 --- a/lib/features/updater/update_available_badge.dart +++ b/lib/features/updater/update_available_badge.dart @@ -9,7 +9,10 @@ import 'package:querya_desktop/features/updater/update_controller.dart'; import 'package:querya_desktop/features/updater/update_dialog.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; -/// Soft pulse period for the update chip (documented chrome constant; see F9). +/// Soft pulse period for the update chip at Full motion (see F9 / #482). +/// +/// Under [QueryaMotionLevel.reduced] the effective period is halved via +/// [QueryaMotion.effectiveDuration]; Off / OS `disableAnimations` stop the pulse. const Duration kUpdateBadgePulsePeriod = Duration(milliseconds: 1400); /// Pulsing title-bar chip when a background update check finds a newer release. @@ -29,6 +32,9 @@ class UpdateAvailableBadgeState extends material.State @visibleForTesting bool get isPulseAnimating => _pulse.isAnimating; + @visibleForTesting + Duration? get pulseDuration => _pulse.duration; + @override void initState() { super.initState(); @@ -73,7 +79,14 @@ class UpdateAvailableBadgeState extends material.State _pulse.value = 0; return; } - if (!_pulse.isAnimating) { + + final period = + QueryaMotion.effectiveDuration(context, kUpdateBadgePulsePeriod); + final periodChanged = _pulse.duration != period; + if (periodChanged) { + _pulse.duration = period; + } + if (!_pulse.isAnimating || periodChanged) { _pulse.repeat(reverse: true); } } @@ -95,6 +108,11 @@ class UpdateAvailableBadgeState extends material.State final wb = context.workbench; // Depend on motion so Off/Reduced rebuilds re-sync the pulse. context.motionDuration(QueryaMotion.fast); + final reduced = + QueryaMotionScope.maybeOf(context) == QueryaMotionLevel.reduced; + // Quieter chrome under Reduced (#482). + final fillAmp = reduced ? 0.04 : 0.08; + final borderAmp = reduced ? 0.12 : 0.25; return material.Padding( padding: const material.EdgeInsets.only(right: 8), @@ -115,12 +133,12 @@ class UpdateAvailableBadgeState extends material.State padding: const material.EdgeInsets.symmetric( horizontal: 10, vertical: 4), decoration: material.BoxDecoration( - color: - wb.accent.withValues(alpha: 0.12 + 0.08 * _pulse.value), + color: wb.accent + .withValues(alpha: 0.12 + fillAmp * _pulse.value), borderRadius: material.BorderRadius.circular(999), border: material.Border.all( - color: - wb.accent.withValues(alpha: 0.35 + 0.25 * _pulse.value), + color: wb.accent + .withValues(alpha: 0.35 + borderAmp * _pulse.value), ), ), child: child, diff --git a/test/core/motion/querya_hover_surface_test.dart b/test/core/motion/querya_hover_surface_test.dart index e0afb04b..d2a86c5d 100644 --- a/test/core/motion/querya_hover_surface_test.dart +++ b/test/core/motion/querya_hover_surface_test.dart @@ -155,4 +155,20 @@ void main() { ); expect(region.cursor, SystemMouseCursors.click); }); + + testWidgets('applies optional border on decoration', (tester) async { + await tester.pumpWidget( + wrap( + QueryaHoverSurface( + border: Border.all(color: const Color(0xFF445566), width: 2), + child: const SizedBox(width: 40, height: 20), + ), + ), + ); + final animated = + tester.widget(find.byType(AnimatedContainer)); + final decoration = animated.decoration! as BoxDecoration; + expect(decoration.border, isA()); + expect((decoration.border! as Border).top.width, 2); + }); } diff --git a/test/features/updater/update_available_badge_test.dart b/test/features/updater/update_available_badge_test.dart index 5e859b0a..6f61d05a 100644 --- a/test/features/updater/update_available_badge_test.dart +++ b/test/features/updater/update_available_badge_test.dart @@ -89,4 +89,36 @@ void main() { expect(find.textContaining('v1.0.0 available'), findsOneWidget); expect(state.isPulseAnimating, isFalse); }); + + testWidgets('reduced motion pulses at half period', (tester) async { + controller.setPendingUpdate( + const UpdateManifest( + version: '2.0.0', + changelog: '', + assets: [], + ), + ); + + await tester.pumpWidget( + queryaThemeTestShell( + child: QueryaMotionScope( + level: QueryaMotionLevel.reduced, + child: material.Scaffold( + body: UpdateAvailableBadge(controller: controller), + ), + ), + ), + ); + await tester.pump(); + + final state = tester.state( + find.byType(UpdateAvailableBadge), + ); + expect(find.textContaining('v2.0.0 available'), findsOneWidget); + expect(state.isPulseAnimating, isTrue); + expect( + state.pulseDuration, + Duration(microseconds: kUpdateBadgePulsePeriod.inMicroseconds ~/ 2), + ); + }); }