Skip to content

Commit 8bfaa4e

Browse files
Merge pull request #487 from QueryaHub/issue/482-hover-surface-badge
ui(motion): adopt HoverSurface; fix update badge Reduced (#482)
2 parents 05f267d + 5215723 commit 8bfaa4e

6 files changed

Lines changed: 134 additions & 85 deletions

File tree

docs/motion-and-high-refresh.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ When reviewing PRs that touch animation:
177177
| Constant | Value | Where |
178178
|----------|-------|--------|
179179
| `kQueryaStaggerStep` | 30 ms | `QueryaStagger` first-paint choreography |
180-
| `kUpdateBadgePulsePeriod` | 1400 ms | Update title-bar chip pulse (chrome; see #363) |
180+
| `kUpdateBadgePulsePeriod` | 1400 ms | Update title-bar chip pulse at Full; Reduced halves via `effectiveDuration`; Off / OS disable stop (#363, #482) |
181181

182182
Checklist for 120 Hz verification: [perf-baseline.md](perf-baseline.md) § Fluid shell.
183183

lib/core/motion/querya_hover_surface.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ class QueryaHoverSurface extends StatefulWidget {
99
super.key,
1010
required this.child,
1111
this.borderRadius,
12+
this.border,
1213
this.padding,
1314
this.hoveredColor,
1415
this.idleColor = Colors.transparent,
@@ -18,6 +19,7 @@ class QueryaHoverSurface extends StatefulWidget {
1819

1920
final Widget child;
2021
final BorderRadius? borderRadius;
22+
final BoxBorder? border;
2123
final EdgeInsetsGeometry? padding;
2224
final Color? hoveredColor;
2325
final Color idleColor;
@@ -46,6 +48,7 @@ class _QueryaHoverSurfaceState extends State<QueryaHoverSurface> {
4648
decoration: BoxDecoration(
4749
color: _hovered ? hovered : widget.idleColor,
4850
borderRadius: widget.borderRadius,
51+
border: widget.border,
4952
),
5053
child: widget.child,
5154
);

lib/features/connections/new_connection_dialog.dart

Lines changed: 58 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,7 @@ import 'package:querya_desktop/core/ui/querya_icons.dart';
55
import 'package:querya_desktop/core/extensions/extension_driver_catalog.dart';
66
import 'package:querya_desktop/core/extensions/local_extension_registry.dart';
77
import 'package:querya_desktop/core/layout/window_layout.dart';
8-
import 'package:querya_desktop/core/motion/querya_motion.dart';
9-
import 'package:querya_desktop/core/motion/querya_motion_context.dart';
8+
import 'package:querya_desktop/core/motion/querya_hover_surface.dart';
109
import 'package:querya_desktop/features/connections/connection_type_choice.dart';
1110
import 'package:querya_desktop/features/connections/driver_icon.dart';
1211
import 'package:querya_desktop/shared/widgets/widgets.dart';
@@ -385,7 +384,7 @@ class _FilterDropdowns extends StatelessWidget {
385384
}
386385
}
387386

388-
class _DbTypeCard extends material.StatefulWidget {
387+
class _DbTypeCard extends material.StatelessWidget {
389388
const _DbTypeCard({
390389
required this.choice,
391390
required this.theme,
@@ -398,89 +397,70 @@ class _DbTypeCard extends material.StatefulWidget {
398397
final bool selected;
399398
final VoidCallback onTap;
400399

401-
@override
402-
material.State<_DbTypeCard> createState() => _DbTypeCardState();
403-
}
404-
405-
class _DbTypeCardState extends material.State<_DbTypeCard> {
406-
bool _hovered = false;
407-
408400
@override
409401
material.Widget build(material.BuildContext context) {
410-
final t = widget.theme;
411-
final highlighted = widget.selected || _hovered;
412-
return material.MouseRegion(
413-
onEnter: (_) => setState(() => _hovered = true),
414-
onExit: (_) => setState(() => _hovered = false),
415-
cursor: material.SystemMouseCursors.click,
416-
child: material.GestureDetector(
417-
onTap: widget.onTap,
418-
child: material.AnimatedContainer(
419-
duration: context.motionDuration(QueryaMotion.fast),
420-
curve: context.motionCurve(QueryaMotion.enter),
421-
padding:
422-
const material.EdgeInsets.symmetric(vertical: 10, horizontal: 8),
423-
decoration: material.BoxDecoration(
424-
color: highlighted
425-
? t.muted.withValues(alpha: 0.4)
426-
: t.muted.withValues(alpha: 0.12),
427-
borderRadius: material.BorderRadius.circular(10),
428-
border: material.Border.all(
429-
color: widget.selected
430-
? t.primary.withValues(alpha: 0.6)
431-
: t.border.withValues(alpha: 0.35),
432-
width: widget.selected ? 1.5 : 1,
402+
final t = theme;
403+
final highlight = t.muted.withValues(alpha: 0.4);
404+
return QueryaHoverSurface(
405+
borderRadius: material.BorderRadius.circular(10),
406+
padding:
407+
const material.EdgeInsets.symmetric(vertical: 10, horizontal: 8),
408+
idleColor: selected ? highlight : t.muted.withValues(alpha: 0.12),
409+
hoveredColor: highlight,
410+
border: material.Border.all(
411+
color: selected
412+
? t.primary.withValues(alpha: 0.6)
413+
: t.border.withValues(alpha: 0.35),
414+
width: selected ? 1.5 : 1,
415+
),
416+
onTap: onTap,
417+
child: material.Column(
418+
crossAxisAlignment: material.CrossAxisAlignment.stretch,
419+
children: [
420+
material.Expanded(
421+
child: material.Center(
422+
child: material.SizedBox(
423+
width: 52,
424+
height: 52,
425+
child: DriverIcon(
426+
filePath: choice.iconFile,
427+
assetPath: choice.iconAsset,
428+
size: 52,
429+
fallbackIcon: choice.icon,
430+
),
431+
),
433432
),
434433
),
435-
child: material.Column(
436-
crossAxisAlignment: material.CrossAxisAlignment.stretch,
437-
children: [
438-
material.Expanded(
439-
child: material.Center(
440-
child: material.SizedBox(
441-
width: 52,
442-
height: 52,
443-
child: DriverIcon(
444-
filePath: widget.choice.iconFile,
445-
assetPath: widget.choice.iconAsset,
446-
size: 52,
447-
fallbackIcon: widget.choice.icon,
434+
const material.SizedBox(height: 6),
435+
material.LayoutBuilder(
436+
builder: (context, lc) {
437+
return material.SizedBox(
438+
height: 38,
439+
child: material.FittedBox(
440+
fit: material.BoxFit.scaleDown,
441+
alignment: material.Alignment.center,
442+
child: material.ConstrainedBox(
443+
constraints: material.BoxConstraints(
444+
maxWidth: math.max(48.0, lc.maxWidth),
448445
),
449-
),
450-
),
451-
),
452-
const material.SizedBox(height: 6),
453-
material.LayoutBuilder(
454-
builder: (context, lc) {
455-
return material.SizedBox(
456-
height: 38,
457-
child: material.FittedBox(
458-
fit: material.BoxFit.scaleDown,
459-
alignment: material.Alignment.center,
460-
child: material.ConstrainedBox(
461-
constraints: material.BoxConstraints(
462-
maxWidth: math.max(48.0, lc.maxWidth),
463-
),
464-
child: material.Text(
465-
widget.choice.label,
466-
textAlign: material.TextAlign.center,
467-
maxLines: 2,
468-
overflow: material.TextOverflow.ellipsis,
469-
style: material.TextStyle(
470-
fontSize: 13,
471-
fontWeight: material.FontWeight.w600,
472-
height: 1.2,
473-
color: t.foreground,
474-
),
475-
),
446+
child: material.Text(
447+
choice.label,
448+
textAlign: material.TextAlign.center,
449+
maxLines: 2,
450+
overflow: material.TextOverflow.ellipsis,
451+
style: material.TextStyle(
452+
fontSize: 13,
453+
fontWeight: material.FontWeight.w600,
454+
height: 1.2,
455+
color: t.foreground,
476456
),
477457
),
478-
);
479-
},
480-
),
481-
],
458+
),
459+
),
460+
);
461+
},
482462
),
483-
),
463+
],
484464
),
485465
);
486466
}

lib/features/updater/update_available_badge.dart

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@ import 'package:querya_desktop/features/updater/update_controller.dart';
99
import 'package:querya_desktop/features/updater/update_dialog.dart';
1010
import 'package:shadcn_flutter/shadcn_flutter.dart';
1111

12-
/// Soft pulse period for the update chip (documented chrome constant; see F9).
12+
/// Soft pulse period for the update chip at Full motion (see F9 / #482).
13+
///
14+
/// Under [QueryaMotionLevel.reduced] the effective period is halved via
15+
/// [QueryaMotion.effectiveDuration]; Off / OS `disableAnimations` stop the pulse.
1316
const Duration kUpdateBadgePulsePeriod = Duration(milliseconds: 1400);
1417

1518
/// Pulsing title-bar chip when a background update check finds a newer release.
@@ -29,6 +32,9 @@ class UpdateAvailableBadgeState extends material.State<UpdateAvailableBadge>
2932
@visibleForTesting
3033
bool get isPulseAnimating => _pulse.isAnimating;
3134

35+
@visibleForTesting
36+
Duration? get pulseDuration => _pulse.duration;
37+
3238
@override
3339
void initState() {
3440
super.initState();
@@ -73,7 +79,14 @@ class UpdateAvailableBadgeState extends material.State<UpdateAvailableBadge>
7379
_pulse.value = 0;
7480
return;
7581
}
76-
if (!_pulse.isAnimating) {
82+
83+
final period =
84+
QueryaMotion.effectiveDuration(context, kUpdateBadgePulsePeriod);
85+
final periodChanged = _pulse.duration != period;
86+
if (periodChanged) {
87+
_pulse.duration = period;
88+
}
89+
if (!_pulse.isAnimating || periodChanged) {
7790
_pulse.repeat(reverse: true);
7891
}
7992
}
@@ -95,6 +108,11 @@ class UpdateAvailableBadgeState extends material.State<UpdateAvailableBadge>
95108
final wb = context.workbench;
96109
// Depend on motion so Off/Reduced rebuilds re-sync the pulse.
97110
context.motionDuration(QueryaMotion.fast);
111+
final reduced =
112+
QueryaMotionScope.maybeOf(context) == QueryaMotionLevel.reduced;
113+
// Quieter chrome under Reduced (#482).
114+
final fillAmp = reduced ? 0.04 : 0.08;
115+
final borderAmp = reduced ? 0.12 : 0.25;
98116

99117
return material.Padding(
100118
padding: const material.EdgeInsets.only(right: 8),
@@ -115,12 +133,12 @@ class UpdateAvailableBadgeState extends material.State<UpdateAvailableBadge>
115133
padding: const material.EdgeInsets.symmetric(
116134
horizontal: 10, vertical: 4),
117135
decoration: material.BoxDecoration(
118-
color:
119-
wb.accent.withValues(alpha: 0.12 + 0.08 * _pulse.value),
136+
color: wb.accent
137+
.withValues(alpha: 0.12 + fillAmp * _pulse.value),
120138
borderRadius: material.BorderRadius.circular(999),
121139
border: material.Border.all(
122-
color:
123-
wb.accent.withValues(alpha: 0.35 + 0.25 * _pulse.value),
140+
color: wb.accent
141+
.withValues(alpha: 0.35 + borderAmp * _pulse.value),
124142
),
125143
),
126144
child: child,

test/core/motion/querya_hover_surface_test.dart

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,4 +155,20 @@ void main() {
155155
);
156156
expect(region.cursor, SystemMouseCursors.click);
157157
});
158+
159+
testWidgets('applies optional border on decoration', (tester) async {
160+
await tester.pumpWidget(
161+
wrap(
162+
QueryaHoverSurface(
163+
border: Border.all(color: const Color(0xFF445566), width: 2),
164+
child: const SizedBox(width: 40, height: 20),
165+
),
166+
),
167+
);
168+
final animated =
169+
tester.widget<AnimatedContainer>(find.byType(AnimatedContainer));
170+
final decoration = animated.decoration! as BoxDecoration;
171+
expect(decoration.border, isA<Border>());
172+
expect((decoration.border! as Border).top.width, 2);
173+
});
158174
}

test/features/updater/update_available_badge_test.dart

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,4 +89,36 @@ void main() {
8989
expect(find.textContaining('v1.0.0 available'), findsOneWidget);
9090
expect(state.isPulseAnimating, isFalse);
9191
});
92+
93+
testWidgets('reduced motion pulses at half period', (tester) async {
94+
controller.setPendingUpdate(
95+
const UpdateManifest(
96+
version: '2.0.0',
97+
changelog: '',
98+
assets: [],
99+
),
100+
);
101+
102+
await tester.pumpWidget(
103+
queryaThemeTestShell(
104+
child: QueryaMotionScope(
105+
level: QueryaMotionLevel.reduced,
106+
child: material.Scaffold(
107+
body: UpdateAvailableBadge(controller: controller),
108+
),
109+
),
110+
),
111+
);
112+
await tester.pump();
113+
114+
final state = tester.state<UpdateAvailableBadgeState>(
115+
find.byType(UpdateAvailableBadge),
116+
);
117+
expect(find.textContaining('v2.0.0 available'), findsOneWidget);
118+
expect(state.isPulseAnimating, isTrue);
119+
expect(
120+
state.pulseDuration,
121+
Duration(microseconds: kUpdateBadgePulsePeriod.inMicroseconds ~/ 2),
122+
);
123+
});
92124
}

0 commit comments

Comments
 (0)