diff --git a/lib/features/settings/theme_picker_button.dart b/lib/features/settings/theme_picker_button.dart index a8b9319..01b8cea 100644 --- a/lib/features/settings/theme_picker_button.dart +++ b/lib/features/settings/theme_picker_button.dart @@ -76,7 +76,10 @@ class _ThemePickerButtonState extends material.State { material.ScrollController(); final material.TextEditingController _searchController = material.TextEditingController(); + final material.ValueNotifier _menuOpen = + material.ValueNotifier(false); bool _triggerHovered = false; + bool _closingWithExit = false; String? _previewThemeId; String? _previewThemeLabel; QueryaTheme? _previewTheme; @@ -97,9 +100,26 @@ class _ThemePickerButtonState extends material.State { _searchController.removeListener(_onSearchChanged); _searchController.dispose(); _scrollController.dispose(); + _menuOpen.dispose(); super.dispose(); } + /// Plays exit fade-slide, then removes the [MenuAnchor] overlay (#499). + Future _closeWithExit() async { + if (!_controller.isOpen || _closingWithExit) return; + _closingWithExit = true; + _menuOpen.value = false; + final duration = context.motionDuration(QueryaMotion.standard); + if (duration > QueryaMotion.instant) { + await Future.delayed(duration); + } + if (!mounted) return; + if (_controller.isOpen) { + _controller.close(); + } + _closingWithExit = false; + } + void _resetPreviewState() { _previewDebounce?.cancel(); _previewRequestSerial++; @@ -192,6 +212,14 @@ class _ThemePickerButtonState extends material.State { final anchor = material.MenuAnchor( controller: _controller, + onOpen: () { + _closingWithExit = false; + _menuOpen.value = true; + }, + onClose: () { + _closingWithExit = false; + _menuOpen.value = false; + }, crossAxisUnconstrained: false, alignmentOffset: material.Offset( 0, @@ -223,10 +251,13 @@ class _ThemePickerButtonState extends material.State { ), ), menuChildren: [ - material.SizedBox( - width: menuWidth, - height: menuHeight, - child: _buildMenuPanel(context, cs), + _ThemePickerMenuEnter( + openNotifier: _menuOpen, + child: material.SizedBox( + width: menuWidth, + height: menuHeight, + child: _buildMenuPanel(context, cs), + ), ), ], builder: (context, controller, child) { @@ -354,7 +385,7 @@ class _ThemePickerButtonState extends material.State { widget.onSelected(theme.id); _clearSearch(); _resetPreviewState(); - _controller.close(); + unawaited(_closeWithExit()); }, ); }, @@ -403,16 +434,10 @@ class _ThemePickerButtonState extends material.State { ? material.MainAxisSize.max : material.MainAxisSize.min, children: [ - material.Expanded( - child: material.Text( - _triggerLabel, - maxLines: 1, - overflow: material.TextOverflow.ellipsis, - style: QueryaDropdownTokens.triggerTextStyle( - context, - _enabled ? cs.popoverForeground : cs.mutedForeground, - ), - ), + _triggerLabelText( + context: context, + cs: cs, + expand: widget.expandToParent || fieldWidth != null, ), material.SizedBox(width: chevronGap), material.Icon( @@ -433,7 +458,7 @@ class _ThemePickerButtonState extends material.State { onTap: _enabled ? () { if (controller.isOpen) { - controller.close(); + unawaited(_closeWithExit()); } else { _clearSearch(); _resetPreviewState(); @@ -446,6 +471,61 @@ class _ThemePickerButtonState extends material.State { ), ); } + + material.Widget _triggerLabelText({ + required material.BuildContext context, + required ColorScheme cs, + required bool expand, + }) { + final text = material.Text( + _triggerLabel, + maxLines: 1, + overflow: material.TextOverflow.ellipsis, + style: QueryaDropdownTokens.triggerTextStyle( + context, + _enabled ? cs.popoverForeground : cs.mutedForeground, + ), + ); + if (expand) { + return material.Expanded(child: text); + } + return text; + } +} + +/// Enter/exit fade-slide for theme menu body while the overlay stays mounted. +class _ThemePickerMenuEnter extends material.StatelessWidget { + const _ThemePickerMenuEnter({ + required this.openNotifier, + required this.child, + }); + + final material.ValueNotifier openNotifier; + final material.Widget child; + + @override + material.Widget build(material.BuildContext context) { + final duration = context.motionDuration(QueryaMotion.standard); + final enter = context.motionCurve(QueryaMotion.enter); + final exit = context.motionCurve(QueryaMotion.exit); + return material.ValueListenableBuilder( + valueListenable: openNotifier, + builder: (context, open, _) { + 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( + opacity: open ? 1 : 0, + duration: duration, + curve: curve, + child: child, + ), + ); + }, + ); + } } class _ThemePickerRow extends material.StatefulWidget { diff --git a/test/features/settings/theme_picker_button_test.dart b/test/features/settings/theme_picker_button_test.dart index 6defda1..fe40911 100644 --- a/test/features/settings/theme_picker_button_test.dart +++ b/test/features/settings/theme_picker_button_test.dart @@ -581,6 +581,60 @@ void main() { expect(picked, ThemeController.builtinQueryaLightId); }); + + testWidgets('compact trigger uses min mainAxisSize (no forced Expanded)', + (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: ThemePickerButton( + themes: _fakeThemes(3), + selectedThemeId: 'theme-0', + onSelected: (_) {}, + ), + ), + ), + ); + await tester.pump(); + + final rows = tester.widgetList(find.byType(material.Row)); + final triggerRow = rows.firstWhere( + (row) => row.mainAxisSize == material.MainAxisSize.min, + ); + expect(triggerRow.mainAxisSize, material.MainAxisSize.min); + expect( + triggerRow.children.whereType(), + isEmpty, + ); + }); + + testWidgets('selecting theme keeps overlay briefly for exit motion', + (tester) async { + final themes = _fakeThemes(5); + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: ThemePickerButton( + themes: themes, + selectedThemeId: 'theme-0', + onSelected: (_) {}, + ), + ), + ), + ); + await tester.pump(); + + await tester.tap(find.text('Theme 00')); + await tester.pumpAndSettle(); + expect(find.byType(material.ListView), findsOneWidget); + + await tester.tap(find.text('Theme 01')); + await tester.pump(); // start exit; overlay still mounted + expect(find.byType(material.ListView), findsOneWidget); + + await tester.pumpAndSettle(); + expect(find.byType(material.ListView), findsNothing); + }); }); group('filterThemeDefinitions metadata', () {