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
112 changes: 96 additions & 16 deletions lib/features/settings/theme_picker_button.dart
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,10 @@ class _ThemePickerButtonState extends material.State<ThemePickerButton> {
material.ScrollController();
final material.TextEditingController _searchController =
material.TextEditingController();
final material.ValueNotifier<bool> _menuOpen =
material.ValueNotifier<bool>(false);
bool _triggerHovered = false;
bool _closingWithExit = false;
String? _previewThemeId;
String? _previewThemeLabel;
QueryaTheme? _previewTheme;
Expand All @@ -97,9 +100,26 @@ class _ThemePickerButtonState extends material.State<ThemePickerButton> {
_searchController.removeListener(_onSearchChanged);
_searchController.dispose();
_scrollController.dispose();
_menuOpen.dispose();
super.dispose();
}

/// Plays exit fade-slide, then removes the [MenuAnchor] overlay (#499).
Future<void> _closeWithExit() async {
if (!_controller.isOpen || _closingWithExit) return;
_closingWithExit = true;
_menuOpen.value = false;
final duration = context.motionDuration(QueryaMotion.standard);
if (duration > QueryaMotion.instant) {
await Future<void>.delayed(duration);
}
if (!mounted) return;
if (_controller.isOpen) {
_controller.close();
}
_closingWithExit = false;
}

void _resetPreviewState() {
_previewDebounce?.cancel();
_previewRequestSerial++;
Expand Down Expand Up @@ -192,6 +212,14 @@ class _ThemePickerButtonState extends material.State<ThemePickerButton> {

final anchor = material.MenuAnchor(
controller: _controller,
onOpen: () {
_closingWithExit = false;
_menuOpen.value = true;
},
onClose: () {
_closingWithExit = false;
_menuOpen.value = false;
},
crossAxisUnconstrained: false,
alignmentOffset: material.Offset(
0,
Expand Down Expand Up @@ -223,10 +251,13 @@ class _ThemePickerButtonState extends material.State<ThemePickerButton> {
),
),
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) {
Expand Down Expand Up @@ -354,7 +385,7 @@ class _ThemePickerButtonState extends material.State<ThemePickerButton> {
widget.onSelected(theme.id);
_clearSearch();
_resetPreviewState();
_controller.close();
unawaited(_closeWithExit());
},
);
},
Expand Down Expand Up @@ -403,16 +434,10 @@ class _ThemePickerButtonState extends material.State<ThemePickerButton> {
? 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(
Expand All @@ -433,7 +458,7 @@ class _ThemePickerButtonState extends material.State<ThemePickerButton> {
onTap: _enabled
? () {
if (controller.isOpen) {
controller.close();
unawaited(_closeWithExit());
} else {
_clearSearch();
_resetPreviewState();
Expand All @@ -446,6 +471,61 @@ class _ThemePickerButtonState extends material.State<ThemePickerButton> {
),
);
}

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<bool> 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<bool>(
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 {
Expand Down
54 changes: 54 additions & 0 deletions test/features/settings/theme_picker_button_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<material.Row>(find.byType(material.Row));
final triggerRow = rows.firstWhere(
(row) => row.mainAxisSize == material.MainAxisSize.min,
);
expect(triggerRow.mainAxisSize, material.MainAxisSize.min);
expect(
triggerRow.children.whereType<material.Expanded>(),
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', () {
Expand Down
Loading