diff --git a/lib/shared/widgets/app_dialog.dart b/lib/shared/widgets/app_dialog.dart index 01ffa2fb..73004af5 100644 --- a/lib/shared/widgets/app_dialog.dart +++ b/lib/shared/widgets/app_dialog.dart @@ -4,10 +4,12 @@ import 'package:flutter/material.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_spring.dart'; /// Shows a modal dialog with a frosted, dimmed backdrop over the app. /// /// Use instead of [showDialog] so every overlay has consistent blur. +/// Enter: fade + slight slide; exit uses [QueryaMotion.exit] via reverseCurve. Future showAppDialog({ required BuildContext context, required WidgetBuilder builder, @@ -34,7 +36,7 @@ Future showAppDialog({ ); } -class _BlurredDialogScaffold extends StatelessWidget { +class _BlurredDialogScaffold extends StatefulWidget { const _BlurredDialogScaffold({ required this.barrierDismissible, required this.onDismiss, @@ -48,21 +50,62 @@ class _BlurredDialogScaffold extends StatelessWidget { final Widget child; @override - Widget build(BuildContext context) { - final curved = animation.drive( - CurveTween(curve: context.motionCurve(QueryaMotion.enter)), + State<_BlurredDialogScaffold> createState() => _BlurredDialogScaffoldState(); +} + +class _BlurredDialogScaffoldState extends State<_BlurredDialogScaffold> { + CurvedAnimation? _curved; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _rebuildCurved(); + } + + @override + void didUpdateWidget(covariant _BlurredDialogScaffold oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.animation != widget.animation) { + _rebuildCurved(); + } + } + + void _rebuildCurved() { + _curved?.dispose(); + final useSpring = QueryaSpring.springsEnabled(context); + _curved = CurvedAnimation( + parent: widget.animation, + curve: context.motionCurve( + useSpring ? QueryaMotion.emphasized : QueryaMotion.enter, + ), + reverseCurve: context.motionCurve(QueryaMotion.exit), ); + } + + @override + void dispose() { + _curved?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final curved = _curved!; + final slide = Tween( + begin: const Offset(0, 0.03), + end: Offset.zero, + ).animate(curved); + return Material( type: MaterialType.transparency, child: Stack( fit: StackFit.expand, children: [ - // ── Backdrop: animates blur sigma and dim alpha directly, without - // being wrapped in a FadeTransition, so blur starts immediately. + // Backdrop: animates blur sigma and dim alpha directly (no FadeTransition). Positioned.fill( child: GestureDetector( behavior: HitTestBehavior.opaque, - onTap: barrierDismissible ? onDismiss : null, + onTap: widget.barrierDismissible ? widget.onDismiss : null, child: AnimatedBuilder( animation: curved, builder: (ctx, _) { @@ -80,18 +123,14 @@ class _BlurredDialogScaffold extends StatelessWidget { ), ), ), - // ── Dialog card: fades + scales up, independently of the backdrop. + // Dialog card: fade-slide enter; exit uses reverseCurve (QueryaMotion.exit). Center( - child: AnimatedBuilder( - animation: curved, - builder: (ctx, inner) => FadeTransition( - opacity: curved, - child: ScaleTransition( - scale: Tween(begin: 0.92, end: 1.0).animate(curved), - child: inner, - ), + child: FadeTransition( + opacity: curved, + child: SlideTransition( + position: slide, + child: widget.child, ), - child: child, ), ), ], diff --git a/lib/shared/widgets/querya_dropdown.dart b/lib/shared/widgets/querya_dropdown.dart index 486715f6..6c622b82 100644 --- a/lib/shared/widgets/querya_dropdown.dart +++ b/lib/shared/widgets/querya_dropdown.dart @@ -313,13 +313,14 @@ class _QueryaDropdownMenuEnter extends material.StatelessWidget { @override material.Widget build(material.BuildContext context) { final duration = context.motionDuration(QueryaMotion.standard); - final curve = context.motionCurve(QueryaMotion.enter); + final enter = context.motionCurve(QueryaMotion.enter); + final exit = context.motionCurve(QueryaMotion.exit); return material.ValueListenableBuilder( valueListenable: openNotifier, builder: (context, open, _) { - return material.AnimatedScale( - scale: open ? 1 : 0.96, - alignment: material.Alignment.topCenter, + final curve = open ? enter : exit; + return material.AnimatedSlide( + offset: open ? material.Offset.zero : const material.Offset(0, -0.04), duration: duration, curve: curve, child: material.AnimatedOpacity( diff --git a/test/shared/app_dialog_test.dart b/test/shared/app_dialog_test.dart index 6728acd4..96a775f8 100644 --- a/test/shared/app_dialog_test.dart +++ b/test/shared/app_dialog_test.dart @@ -1,22 +1,35 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/motion/querya_motion.dart'; +import 'package:querya_desktop/core/motion/querya_motion_scope.dart'; import 'package:querya_desktop/shared/widgets/app_dialog.dart'; void main() { - testWidgets('barrierDismissible true closes dialog on backdrop tap', - (tester) async { + Future pumpHost( + WidgetTester tester, { + QueryaMotionLevel level = QueryaMotionLevel.full, + }) async { late BuildContext ctx; - var completed = false; await tester.pumpWidget( MaterialApp( - home: Builder( - builder: (context) { - ctx = context; - return const SizedBox.shrink(); - }, + home: QueryaMotionScope( + level: level, + child: Builder( + builder: (context) { + ctx = context; + return const SizedBox.shrink(); + }, + ), ), ), ); + return ctx; + } + + testWidgets('barrierDismissible true closes dialog on backdrop tap', + (tester) async { + final ctx = await pumpHost(tester); + var completed = false; final future = showAppDialog( context: ctx, @@ -36,17 +49,7 @@ void main() { }); testWidgets('barrierDismissible false ignores backdrop tap', (tester) async { - late BuildContext ctx; - await tester.pumpWidget( - MaterialApp( - home: Builder( - builder: (context) { - ctx = context; - return const SizedBox.shrink(); - }, - ), - ), - ); + final ctx = await pumpHost(tester); final future = showAppDialog( context: ctx, @@ -65,18 +68,9 @@ void main() { await future; }); - testWidgets('showAppDialog uses BackdropFilter on scaffold', (tester) async { - late BuildContext ctx; - await tester.pumpWidget( - MaterialApp( - home: Builder( - builder: (context) { - ctx = context; - return const SizedBox.shrink(); - }, - ), - ), - ); + testWidgets('showAppDialog uses fade-slide (not scale) with BackdropFilter', + (tester) async { + final ctx = await pumpHost(tester); showAppDialog( context: ctx, @@ -85,6 +79,65 @@ void main() { await tester.pump(); expect(find.byType(BackdropFilter), findsWidgets); expect(find.byType(FadeTransition), findsWidgets); - expect(find.byType(ScaleTransition), findsWidgets); + expect(find.byType(SlideTransition), findsWidgets); + expect(find.byType(ScaleTransition), findsNothing); + }); + + testWidgets('enter uses standard duration under full motion', (tester) async { + final ctx = await pumpHost(tester); + showAppDialog( + context: ctx, + builder: (c) => const AlertDialog(title: Text('Timed')), + ); + await tester.pump(); + + final route = ModalRoute.of(tester.element(find.text('Timed'))); + expect(route, isA>()); + // showGeneralDialog uses transitionDuration from showAppDialog call site. + expect( + QueryaMotion.effectiveDuration(ctx, QueryaMotion.standard), + QueryaMotion.standard, + ); + }); + + testWidgets('dismiss animates with exit reverseCurve (opacity decreases)', + (tester) async { + final ctx = await pumpHost(tester); + + final future = showAppDialog( + context: ctx, + builder: (c) => const AlertDialog(title: Text('Leaving')), + ); + await tester.pumpAndSettle(); + + Navigator.of(ctx, rootNavigator: true).pop(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 40)); + + final fades = tester + .widgetList(find.byType(FadeTransition)) + .where((f) => f.opacity.value < 1.0) + .toList(); + expect(fades, isNotEmpty); + + await tester.pumpAndSettle(); + expect(find.text('Leaving'), findsNothing); + await future; + }); + + testWidgets('motion off opens and closes instantly', (tester) async { + final ctx = await pumpHost(tester, level: QueryaMotionLevel.off); + + final future = showAppDialog( + context: ctx, + builder: (c) => const AlertDialog(title: Text('Snap')), + ); + await tester.pump(); + expect(find.text('Snap'), findsOneWidget); + + Navigator.of(ctx, rootNavigator: true).pop(); + await tester.pump(); + expect(find.text('Snap'), findsNothing); + await future; }); } diff --git a/test/shared/querya_dropdown_test.dart b/test/shared/querya_dropdown_test.dart index 7ab3c013..64d7cd39 100644 --- a/test/shared/querya_dropdown_test.dart +++ b/test/shared/querya_dropdown_test.dart @@ -71,6 +71,33 @@ void main() { expect(picked, 2); }); + testWidgets('menu enter uses fade-slide (AnimatedSlide + opacity)', + (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: QueryaDropdown( + value: 'a', + items: const [ + QueryaDropdownItem(value: 'a', label: 'Alpha'), + QueryaDropdownItem(value: 'b', label: 'Beta'), + ], + onSelected: (_) {}, + ), + ), + ), + ); + await tester.pump(); + + await tester.tap(find.text('Alpha')); + await tester.pump(); // menu open, enter animation mid-flight + expect(find.byType(material.AnimatedSlide), findsWidgets); + expect(find.byType(material.AnimatedOpacity), findsWidgets); + expect(find.byType(material.AnimatedScale), findsNothing); + await tester.pumpAndSettle(); + expect(find.text('Beta'), findsOneWidget); + }); + testWidgets('menu anchor constrains width to trigger', (tester) async { await tester.pumpWidget( queryaThemeTestShell(