mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-21 06:52:18 +02:00
feat(ui): add themed menus and adaptive checkbox rows
This commit is contained in:
1 parent
33dca89f28
commit
c5491302a7
8 files changed
+322
-111
No files matched your search
@@ -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';
|
||||
|
||||
@@ -112,7 +112,7 @@ extension _HomeTabCsvImport on _HomeTabState {
|
||||
}),
|
||||
)
|
||||
else
|
||||
CheckboxListTile(
|
||||
AppCheckboxListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(l10n.homeSkipAlreadyDownloaded),
|
||||
value: skipDownloaded,
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user