diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index d0814ae4..51f4beac 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -7,6 +7,7 @@ import 'package:spotiflac_android/screens/track_history_snapshot.dart'; import 'package:spotiflac_android/theme/app_tokens.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/widgets/app_switch.dart'; +import 'package:spotiflac_android/widgets/app_checkbox.dart'; import 'package:spotiflac_android/widgets/app_content_card.dart'; import 'package:spotiflac_android/widgets/app_choice_chip.dart'; import 'package:spotiflac_android/widgets/app_loading_indicator.dart'; @@ -18,6 +19,7 @@ import 'package:spotiflac_android/widgets/app_refresh_indicator.dart'; import 'package:spotiflac_android/widgets/app_shape.dart'; import 'package:spotiflac_android/widgets/expressive_button.dart'; import 'package:spotiflac_android/widgets/app_icon_button.dart'; +import 'package:spotiflac_android/widgets/app_menu_button.dart'; import 'package:spotiflac_android/theme/mornye_icons.dart'; import 'package:spotiflac_android/widgets/mornye_chrome.dart'; import 'package:spotiflac_android/widgets/mornye_context_menu.dart'; diff --git a/lib/screens/home_tab_import.dart b/lib/screens/home_tab_import.dart index 18a112f2..deddf4ca 100644 --- a/lib/screens/home_tab_import.dart +++ b/lib/screens/home_tab_import.dart @@ -112,7 +112,7 @@ extension _HomeTabCsvImport on _HomeTabState { }), ) else - CheckboxListTile( + AppCheckboxListTile( contentPadding: EdgeInsets.zero, title: Text(l10n.homeSkipAlreadyDownloaded), value: skipDownloaded, diff --git a/lib/screens/home_tab_widgets.dart b/lib/screens/home_tab_widgets.dart index 215ee47e..a2df18c4 100644 --- a/lib/screens/home_tab_widgets.dart +++ b/lib/screens/home_tab_widgets.dart @@ -74,7 +74,7 @@ class _SearchProviderDropdown extends ConsumerWidget { return Padding( padding: const EdgeInsets.only(left: 8), - child: PopupMenuButton( + child: AppMenuButton( icon: Row( mainAxisSize: MainAxisSize.min, children: [ @@ -101,93 +101,38 @@ class _SearchProviderDropdown extends ConsumerWidget { ), tooltip: context.l10n.homeChangeSearchProviderTooltip, offset: const Offset(0, 40), - // Material takes the expressive menu shape from popupMenuTheme. - shape: context.isMornye - ? RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)) - : null, - popUpAnimationStyle: AppMotion.surfaceEntrance(context), - onSelected: (String providerId) { + alignStart: true, + onSelected: (providerId) { ref.read(settingsProvider.notifier).setSearchProvider(providerId); onProviderChanged?.call(); }, - itemBuilder: (context) => [ - ...searchProviders.map( - (ext) => PopupMenuItem( + items: [ + for (final ext in searchProviders) + AppMenuItem( value: ext.id, - // Expressive menus mark the selection with an inset pill. - padding: context.isMornye - ? null - : const EdgeInsets.symmetric(horizontal: 4), - child: _searchProviderMenuRow( - context, - selected: currentProvider == ext.id, - child: Row( - children: [ - if (ext.iconPath != null && ext.iconPath!.isNotEmpty) - ClipRRect( - borderRadius: BorderRadius.circular(4), - child: Image.file( - File(ext.iconPath!), - width: 20, - height: 20, - fit: BoxFit.cover, - errorBuilder: (_, e, st) => Icon( - _getIconFromName(ext.searchBehavior?.icon), - size: 20, - color: currentProvider == ext.id - ? colorScheme.primary - : colorScheme.onSurfaceVariant, - ), - ), - ) - else - Icon( - _getIconFromName(ext.searchBehavior?.icon), - size: 20, - color: currentProvider == ext.id - ? colorScheme.primary - : colorScheme.onSurfaceVariant, - ), - const SizedBox(width: 12), - Expanded( - child: Text( - ext.displayName, - style: TextStyle( - fontWeight: currentProvider == ext.id - ? FontWeight.w600 - : FontWeight.normal, + label: ext.displayName, + selected: currentProvider == ext.id, + leading: ext.iconPath != null && ext.iconPath!.isNotEmpty + ? ClipRRect( + borderRadius: BorderRadius.circular(4), + child: Image.file( + File(ext.iconPath!), + width: 20, + height: 20, + fit: BoxFit.cover, + errorBuilder: (_, e, st) => Icon( + _getIconFromName(ext.searchBehavior?.icon), + size: 20, ), ), - ), - if (currentProvider == ext.id) - Icon(Icons.check, size: 18, color: colorScheme.primary), - ], - ), - ), + ) + : Icon(_getIconFromName(ext.searchBehavior?.icon), size: 20), ), - ), ], ), ); } - Widget _searchProviderMenuRow( - BuildContext context, { - required bool selected, - required Widget child, - }) { - if (context.isMornye) return child; - final colorScheme = Theme.of(context).colorScheme; - return Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - decoration: BoxDecoration( - color: selected ? colorScheme.secondaryContainer : null, - borderRadius: BorderRadius.circular(12), - ), - child: child, - ); - } - IconData _getIconFromName(String? iconName) { switch (iconName) { case 'video': diff --git a/lib/screens/settings/log_screen.dart b/lib/screens/settings/log_screen.dart index a245006b..7e158897 100644 --- a/lib/screens/settings/log_screen.dart +++ b/lib/screens/settings/log_screen.dart @@ -6,11 +6,11 @@ import 'package:spotiflac_android/widgets/expressive_button.dart'; import 'package:flutter/services.dart'; import 'package:share_plus/share_plus.dart' show ShareParams, SharePlus; import 'package:spotiflac_android/l10n/l10n.dart'; -import 'package:spotiflac_android/theme/app_motion.dart'; import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/app_icon.dart'; import 'package:spotiflac_android/widgets/app_icon_button.dart'; +import 'package:spotiflac_android/widgets/app_menu_button.dart'; import 'package:spotiflac_android/widgets/app_sliver_header.dart'; import 'package:spotiflac_android/widgets/app_search_field.dart'; import 'package:spotiflac_android/utils/ordered_range_selection.dart'; @@ -349,12 +349,11 @@ class _LogScreenState extends State { tooltip: context.l10n.logCopyLogs, onPressed: _copyLogs, ), - PopupMenuButton( + AppMenuButton( icon: const AppIcon(Icons.more_vert), tooltip: MaterialLocalizations.of( context, ).showMenuTooltip, - popUpAnimationStyle: AppMotion.surfaceEntrance(context), onSelected: (value) { switch (value) { case 'share': @@ -365,22 +364,16 @@ class _LogScreenState extends State { break; } }, - itemBuilder: (context) => [ - PopupMenuItem( + items: [ + AppMenuItem( value: 'share', - child: ListTile( - leading: const AppIcon(Icons.share), - title: Text(context.l10n.logShareLogs), - contentPadding: EdgeInsets.zero, - ), + label: context.l10n.logShareLogs, + leading: const AppIcon(Icons.share), ), - PopupMenuItem( + AppMenuItem( value: 'clear', - child: ListTile( - leading: const AppIcon(Icons.delete_outline), - title: Text(context.l10n.logClearLogs), - contentPadding: EdgeInsets.zero, - ), + label: context.l10n.logClearLogs, + leading: const AppIcon(Icons.delete_outline), ), ], ), diff --git a/lib/widgets/app_checkbox.dart b/lib/widgets/app_checkbox.dart new file mode 100644 index 00000000..8c6bf216 --- /dev/null +++ b/lib/widgets/app_checkbox.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import 'package:material_3_expressive/material_3_expressive.dart'; +import 'package:spotiflac_android/theme/material_expressive.dart'; + +/// Checkbox row for Material dialogs and sheets. Expressive builds use the +/// M3E checkbox; reduced motion falls back to the static Material tile. +/// Mornye call sites keep their own check-circle rows. +class AppCheckboxListTile extends StatelessWidget { + const AppCheckboxListTile({ + super.key, + required this.value, + required this.onChanged, + required this.title, + this.subtitle, + this.secondary, + this.contentPadding, + this.controlAffinity = ListTileControlAffinity.platform, + }); + + final bool value; + final ValueChanged? onChanged; + final Widget title; + final Widget? subtitle; + + /// Icon on the side opposite the checkbox. + final Widget? secondary; + final EdgeInsetsGeometry? contentPadding; + final ListTileControlAffinity controlAffinity; + + @override + Widget build(BuildContext context) { + if (!materialExpressiveEnabled(context)) { + return CheckboxListTile( + value: value, + onChanged: onChanged, + title: title, + subtitle: subtitle, + secondary: secondary, + contentPadding: contentPadding, + controlAffinity: controlAffinity, + ); + } + final checkbox = MaterialExpressiveScope( + child: M3ECheckbox(value: value, onChanged: onChanged), + ); + final checkboxLeads = controlAffinity == ListTileControlAffinity.leading; + return MergeSemantics( + child: ListTile( + title: title, + subtitle: subtitle, + contentPadding: contentPadding, + enabled: onChanged != null, + onTap: onChanged == null ? null : () => onChanged!(!value), + leading: checkboxLeads ? checkbox : secondary, + trailing: checkboxLeads ? secondary : checkbox, + ), + ); + } +} diff --git a/lib/widgets/app_menu_button.dart b/lib/widgets/app_menu_button.dart new file mode 100644 index 00000000..fbc0e95f --- /dev/null +++ b/lib/widgets/app_menu_button.dart @@ -0,0 +1,203 @@ +import 'package:flutter/material.dart'; +import 'package:material_3_expressive/material_3_expressive.dart'; +import 'package:spotiflac_android/theme/app_motion.dart'; +import 'package:spotiflac_android/theme/material_expressive.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/app_icon_button.dart'; + +/// One row of an [AppMenuButton]. +class AppMenuItem { + const AppMenuItem({ + required this.value, + required this.label, + this.leading, + this.selected, + this.destructive = false, + }); + + final T value; + final String label; + final Widget? leading; + + /// Non-null for single-select menus: the row shows a check when true. + final bool? selected; + final bool destructive; +} + +/// Overflow and picker menus. Expressive builds open the M3E menu from an +/// icon button; Mornye and reduced motion keep the popup menu. +class AppMenuButton extends StatelessWidget { + const AppMenuButton({ + super.key, + required this.icon, + required this.items, + required this.onSelected, + this.tooltip, + this.offset = Offset.zero, + this.alignStart = false, + }); + + final Widget icon; + final List> items; + final ValueChanged onSelected; + final String? tooltip; + + /// Popup offset; the M3E menu places itself below the anchor. + final Offset offset; + + /// Opens the M3E menu from the anchor's start edge, for anchors near the + /// leading side; overflow buttons at the trailing edge open from the end. + final bool alignStart; + + @override + Widget build(BuildContext context) { + if (materialExpressiveEnabled(context)) { + return MaterialExpressiveScope(child: _ExpressiveMenuAnchor(this)); + } + final colorScheme = Theme.of(context).colorScheme; + return PopupMenuButton( + icon: icon, + tooltip: tooltip, + offset: offset, + shape: context.isMornye + ? RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)) + : null, + popUpAnimationStyle: AppMotion.surfaceEntrance(context), + onSelected: onSelected, + itemBuilder: (context) => [ + for (final item in items) + PopupMenuItem( + value: item.value, + child: Row( + children: [ + if (item.leading != null) ...[ + IconTheme.merge( + data: IconThemeData( + size: 20, + color: item.destructive + ? colorScheme.error + : item.selected == true + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + ), + child: item.leading!, + ), + const SizedBox(width: 12), + ], + Expanded( + child: Text( + item.label, + style: TextStyle( + color: item.destructive ? colorScheme.error : null, + fontWeight: item.selected == true + ? FontWeight.w600 + : null, + ), + ), + ), + if (item.selected == true) + Icon(Icons.check, size: 18, color: colorScheme.primary), + ], + ), + ), + ], + ); + } +} + +/// Icon button that opens the M3E menu popup, one popup at a time. +/// +/// [M3EMenu] measures its anchor against the nearest overlay but inserts the +/// popup into the root one; beside the navigation rail those differ by the +/// rail width, so the anchor is measured against the root overlay here. +class _ExpressiveMenuAnchor extends StatefulWidget { + const _ExpressiveMenuAnchor(this.menu); + + final AppMenuButton menu; + + @override + State<_ExpressiveMenuAnchor> createState() => + _ExpressiveMenuAnchorState(); +} + +class _ExpressiveMenuAnchorState + extends State<_ExpressiveMenuAnchor> { + OverlayEntry? _entry; + + @override + void dispose() { + _removeMenu(); + super.dispose(); + } + + void _removeMenu() { + final entry = _entry; + _entry = null; + entry?.remove(); + entry?.dispose(); + } + + void _openMenu() { + if (_entry != null || widget.menu.items.isEmpty) return; + final overlay = Overlay.of(context, rootOverlay: true); + final anchorBox = context.findRenderObject(); + final overlayBox = overlay.context.findRenderObject(); + if (anchorBox is! RenderBox || + !anchorBox.attached || + !anchorBox.hasSize || + overlayBox is! RenderBox) { + return; + } + final anchor = MatrixUtils.transformRect( + anchorBox.getTransformTo(overlayBox), + Offset.zero & anchorBox.size, + ); + final menu = widget.menu; + T? selectedValue; + for (final item in menu.items) { + if (item.selected == true) selectedValue = item.value; + } + final themes = InheritedTheme.capture(from: context, to: overlay.context); + final popup = M3EMenuPopup( + anchor: anchor, + historyRoute: ModalRoute.of(context), + position: menu.alignStart + ? M3EMenuAnchorPosition.bottomStart + : M3EMenuAnchorPosition.bottomEnd, + selectedValue: selectedValue, + children: [ + for (final item in menu.items) + if (item.selected != null) + M3EMenuSelectable( + label: item.label, + value: item.value, + leading: item.leading, + selected: item.selected!, + ) + else + M3EMenuEntry( + label: item.label, + value: item.value, + leading: item.leading, + isDestructive: item.destructive, + ), + ], + onSelected: (value) { + if (mounted && value is T) menu.onSelected(value); + }, + onDismiss: () {}, + onRemove: _removeMenu, + ); + _entry = OverlayEntry( + builder: (_) => themes.wrap(MaterialExpressiveScope(child: popup)), + ); + overlay.insert(_entry!); + } + + @override + Widget build(BuildContext context) => AppIconButton( + icon: widget.menu.icon, + tooltip: widget.menu.tooltip, + onPressed: _openMenu, + ); +} diff --git a/lib/widgets/re_enrich_field_dialog.dart b/lib/widgets/re_enrich_field_dialog.dart index 37fc80e7..1db950c4 100644 --- a/lib/widgets/re_enrich_field_dialog.dart +++ b/lib/widgets/re_enrich_field_dialog.dart @@ -3,6 +3,7 @@ import 'package:flutter/cupertino.dart'; import 'package:spotiflac_android/theme/mornye_icons.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/widgets/app_action_button.dart'; +import 'package:spotiflac_android/widgets/app_checkbox.dart'; import '../l10n/app_localizations.dart'; import 'package:spotiflac_android/services/batch_metadata_re_enrich.dart'; import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; @@ -420,7 +421,7 @@ class _ReEnrichFieldSheetState extends State<_ReEnrichFieldSheet> { IconData? icon, }) { if (!context.isMornye) { - return CheckboxListTile( + return AppCheckboxListTile( title: Text(title), value: value, onChanged: onChanged, diff --git a/test/design_system_test.dart b/test/design_system_test.dart index f913fa1f..4cb8f708 100644 --- a/test/design_system_test.dart +++ b/test/design_system_test.dart @@ -811,24 +811,32 @@ void main() { }); group('selection bar', () { - test('root-overlay mounting lives in exactly one place', () { - final offenders = _libSources() - .where( - (file) => - file.readAsStringSync().contains('OverlayEntry(') && - _basename(file) != 'selection_bottom_bar.dart', - ) - .map(_basename) - .toList(); + test( + 'selection bars and menu popups use their dedicated overlay hosts', + () { + // Menus own their popup lifecycle; they are not selection bars. + const overlayHosts = { + 'selection_bottom_bar.dart', + 'app_menu_button.dart', + }; + final offenders = _libSources() + .where( + (file) => + file.readAsStringSync().contains('OverlayEntry(') && + !overlayHosts.contains(_basename(file)), + ) + .map(_basename) + .toList(); - expect( - offenders, - isEmpty, - reason: - 'Selection bars must mount through SelectionOverlayController so ' - 'they animate identically and clear the shell navigation bar.', - ); - }); + expect( + offenders, + isEmpty, + reason: + 'Selection bars must mount through SelectionOverlayController so ' + 'they animate identically and clear the shell navigation bar.', + ); + }, + ); testWidgets('shell host keeps modal routes above the selection bar', ( tester,