feat(ui): add themed menus and adaptive checkbox rows

This commit is contained in:
zarzet committed 2026-10-15 16:00:02 +07:00
1 parent 33dca89f28
commit c5491302a7
8 files changed
+322 -111

No files matched your search

+2
View File
@@ -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';
+1 -1
View File
@@ -112,7 +112,7 @@ extension _HomeTabCsvImport on _HomeTabState {
}),
)
else
CheckboxListTile(
AppCheckboxListTile(
contentPadding: EdgeInsets.zero,
title: Text(l10n.homeSkipAlreadyDownloaded),
value: skipDownloaded,
+21 -76
View File
@@ -74,7 +74,7 @@ class _SearchProviderDropdown extends ConsumerWidget {
return Padding(
padding: const EdgeInsets.only(left: 8),
child: PopupMenuButton<String>(
child: AppMenuButton<String>(
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<String>(
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':
+9 -16
View File
@@ -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<LogScreen> {
tooltip: context.l10n.logCopyLogs,
onPressed: _copyLogs,
),
PopupMenuButton<String>(
AppMenuButton<String>(
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<LogScreen> {
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),
),
],
),
+59
View File
@@ -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<bool?>? 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,
),
);
}
}
+203
View File
@@ -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<T extends Object> {
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<T extends Object> 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<AppMenuItem<T>> items;
final ValueChanged<T> 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<T>(this));
}
final colorScheme = Theme.of(context).colorScheme;
return PopupMenuButton<T>(
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<T>(
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<T extends Object> extends StatefulWidget {
const _ExpressiveMenuAnchor(this.menu);
final AppMenuButton<T> menu;
@override
State<_ExpressiveMenuAnchor<T>> createState() =>
_ExpressiveMenuAnchorState<T>();
}
class _ExpressiveMenuAnchorState<T extends Object>
extends State<_ExpressiveMenuAnchor<T>> {
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<T>(
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,
);
}
+2 -1
View File
@@ -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,
+25 -17
View File
@@ -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,