Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/motion-and-high-refresh.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
3 changes: 3 additions & 0 deletions lib/core/motion/querya_hover_surface.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -46,6 +48,7 @@ class _QueryaHoverSurfaceState extends State<QueryaHoverSurface> {
decoration: BoxDecoration(
color: _hovered ? hovered : widget.idleColor,
borderRadius: widget.borderRadius,
border: widget.border,
),
child: widget.child,
);
Expand Down
136 changes: 58 additions & 78 deletions lib/features/connections/new_connection_dialog.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand All @@ -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,
),
),
);
},
),
],
),
),
);
},
),
),
],
),
);
}
Expand Down
30 changes: 24 additions & 6 deletions lib/features/updater/update_available_badge.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -29,6 +32,9 @@ class UpdateAvailableBadgeState extends material.State<UpdateAvailableBadge>
@visibleForTesting
bool get isPulseAnimating => _pulse.isAnimating;

@visibleForTesting
Duration? get pulseDuration => _pulse.duration;

@override
void initState() {
super.initState();
Expand Down Expand Up @@ -73,7 +79,14 @@ class UpdateAvailableBadgeState extends material.State<UpdateAvailableBadge>
_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);
}
}
Expand All @@ -95,6 +108,11 @@ class UpdateAvailableBadgeState extends material.State<UpdateAvailableBadge>
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),
Expand All @@ -115,12 +133,12 @@ class UpdateAvailableBadgeState extends material.State<UpdateAvailableBadge>
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,
Expand Down
16 changes: 16 additions & 0 deletions test/core/motion/querya_hover_surface_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<AnimatedContainer>(find.byType(AnimatedContainer));
final decoration = animated.decoration! as BoxDecoration;
expect(decoration.border, isA<Border>());
expect((decoration.border! as Border).top.width, 2);
});
}
32 changes: 32 additions & 0 deletions test/features/updater/update_available_badge_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<UpdateAvailableBadgeState>(
find.byType(UpdateAvailableBadge),
);
expect(find.textContaining('v2.0.0 available'), findsOneWidget);
expect(state.isPulseAnimating, isTrue);
expect(
state.pulseDuration,
Duration(microseconds: kUpdateBadgePulsePeriod.inMicroseconds ~/ 2),
);
});
}
Loading