feat: OrderedMultiSelectDialog (#1290)

* tweak

* feat: OrderedMultiSelectDialog
This commit is contained in:
My-Responsitories
2025-09-20 17:24:45 +08:00
committed by GitHub
parent 96586f130f
commit 96539cc64c
8 changed files with 571 additions and 180 deletions

View File

@@ -25,7 +25,7 @@ class MemberAudioItem extends StatelessWidget {
onTap: () async {
// TODO music play
final aid = item.aid;
if (aid != null) {
if (aid != null && aid != 0) {
final cid = await SearchHttp.ab2c(aid: aid);
if (cid != null) {
PageUtils.toVideoPage(cid: cid, aid: aid);

View File

@@ -7,7 +7,7 @@ import 'package:PiliPlus/models/common/video/live_quality.dart';
import 'package:PiliPlus/models/common/video/video_decode_type.dart';
import 'package:PiliPlus/models/common/video/video_quality.dart';
import 'package:PiliPlus/pages/setting/models/model.dart';
import 'package:PiliPlus/pages/setting/widgets/multi_select_dialog.dart';
import 'package:PiliPlus/pages/setting/widgets/ordered_multi_select_dialog.dart';
import 'package:PiliPlus/pages/setting/widgets/select_dialog.dart';
import 'package:PiliPlus/plugin/pl_player/models/hwdec_type.dart';
import 'package:PiliPlus/utils/storage.dart';
@@ -345,10 +345,10 @@ List<SettingsModel> get videoSettings => [
leading: const Icon(Icons.memory_outlined),
getSubtitle: () => '当前:${Pref.hardwareDecoding}此项即mpv的--hwdec',
onTap: (setState) async {
final result = await showDialog<Set<String>>(
final result = await showDialog<List<String>>(
context: Get.context!,
builder: (context) {
return MultiSelectDialog<String>(
return OrderedMultiSelectDialog<String>(
title: '硬解模式',
initValues: Pref.hardwareDecoding.split(','),
values: {

View File

@@ -0,0 +1,58 @@
import 'package:flutter/material.dart';
class OrderedCheckbox extends StatelessWidget {
const OrderedCheckbox({
super.key,
required this.value,
required this.onChanged,
}) : assert(value == null || value < 100);
final int? value;
final ValueChanged<int?>? onChanged;
bool get selected => value != null;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final child = DecoratedBox(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(1.5)),
border: Border.all(
color: selected
? theme.colorScheme.primary
: theme.colorScheme.onSurface,
width: 1.6,
strokeAlign: BorderSide.strokeAlignCenter,
),
color: selected ? theme.colorScheme.primary : null,
),
child: selected
? SizedBox.square(
dimension: 16.5,
child: Center(
child: Text(
value.toString(),
style: TextStyle(
inherit: false,
color: theme.colorScheme.onPrimary,
fontSize: 12,
fontWeight: FontWeight.bold,
shadows: theme.iconTheme.shadows,
height: 1.0,
leadingDistribution: TextLeadingDistribution.even,
),
),
),
)
: const SizedBox.square(dimension: 16.5),
);
if (onChanged != null) {
return InkWell(
onTap: () => onChanged!(value),
child: child,
);
}
return child;
}
}

View File

@@ -0,0 +1,214 @@
import 'package:PiliPlus/pages/setting/widgets/checkbox_num.dart';
import 'package:flutter/material.dart';
class OrderedCheckboxListTile extends StatelessWidget {
/// Creates a combination of a list tile and a checkbox.
///
/// The checkbox tile itself does not maintain any state. Instead, when the
/// state of the checkbox changes, the widget calls the [onChanged] callback.
/// Most widgets that use a checkbox will listen for the [onChanged] callback
/// and rebuild the checkbox tile with a new [value] to update the visual
/// appearance of the checkbox.
///
/// The following arguments are required:
///
/// * [value], which determines whether the checkbox is checked. The [value]
/// can only be null if [tristate] is true.
/// * [onChanged], which is called when the value of the checkbox should
/// change. It can be set to null to disable the checkbox.
const OrderedCheckboxListTile({
super.key,
required this.value,
required this.onChanged,
this.activeColor,
this.visualDensity,
this.focusNode,
this.autofocus = false,
this.shape,
this.tileColor,
this.title,
this.subtitle,
this.isThreeLine,
this.dense,
this.trailing,
this.contentPadding,
this.selectedTileColor,
this.onFocusChange,
this.enableFeedback,
this.checkboxScaleFactor = 1.0,
this.titleAlignment,
this.internalAddSemanticForOnTap = false,
}) : assert(isThreeLine != true || subtitle != null);
/// Whether this checkbox is checked.
final int? value;
/// Called when the value of the checkbox should change.
///
/// The checkbox passes the new value to the callback but does not actually
/// change state until the parent widget rebuilds the checkbox tile with the
/// new value.
///
/// If null, the checkbox will be displayed as disabled.
///
/// {@tool snippet}
///
/// The callback provided to [onChanged] should update the state of the parent
/// [StatefulWidget] using the [State.setState] method, so that the parent
/// gets rebuilt; for example:
///
/// ```dart
/// CheckboxListTile(
/// value: _throwShotAway,
/// onChanged: (bool? newValue) {
/// setState(() {
/// _throwShotAway = newValue;
/// });
/// },
/// title: const Text('Throw away your shot'),
/// )
/// ```
/// {@end-tool}
final ValueChanged<int?>? onChanged;
/// The color to use when this checkbox is checked.
///
/// Defaults to [ColorScheme.secondary] of the current [Theme].
final Color? activeColor;
/// Defines how compact the list tile's layout will be.
///
/// {@macro flutter.material.themedata.visualDensity}
final VisualDensity? visualDensity;
/// {@macro flutter.widgets.Focus.focusNode}
final FocusNode? focusNode;
/// {@macro flutter.widgets.Focus.autofocus}
final bool autofocus;
/// {@macro flutter.material.ListTile.shape}
final ShapeBorder? shape;
/// {@macro flutter.material.ListTile.tileColor}
final Color? tileColor;
/// The primary content of the list tile.
///
/// Typically a [Text] widget.
final Widget? title;
/// Additional content displayed below the title.
///
/// Typically a [Text] widget.
final Widget? subtitle;
/// A widget to display on the opposite side of the tile from the checkbox.
///
/// Typically an [Icon] widget.
final Widget? trailing;
/// Whether this list tile is intended to display three lines of text.
///
/// If null, the value from [ListTileThemeData.isThreeLine] is used.
/// If that is also null, the value from [ThemeData.listTileTheme] is used.
/// If still null, the default value is `false`.
final bool? isThreeLine;
/// Whether this list tile is part of a vertically dense list.
///
/// If this property is null then its value is based on [ListTileThemeData.dense].
final bool? dense;
/// Defines insets surrounding the tile's contents.
///
/// This value will surround the [Checkbox], [title], [subtitle], and [trailing]
/// widgets in [OrderedCheckboxListTile].
///
/// When the value is null, the [contentPadding] is `EdgeInsets.symmetric(horizontal: 16.0)`.
final EdgeInsetsGeometry? contentPadding;
/// If non-null, defines the background color when [OrderedCheckboxListTile.selected] is true.
final Color? selectedTileColor;
/// {@macro flutter.material.inkwell.onFocusChange}
final ValueChanged<bool>? onFocusChange;
/// {@macro flutter.material.ListTile.enableFeedback}
///
/// See also:
///
/// * [Feedback] for providing platform-specific feedback to certain actions.
final bool? enableFeedback;
/// Defines how [ListTile.leading] and [ListTile.trailing] are
/// vertically aligned relative to the [ListTile]'s titles
/// ([ListTile.title] and [ListTile.subtitle]).
///
/// If this property is null then [ListTileThemeData.titleAlignment]
/// is used. If that is also null then [ListTileTitleAlignment.threeLine]
/// is used.
///
/// See also:
///
/// * [ListTileTheme.of], which returns the nearest [ListTileTheme]'s
/// [ListTileThemeData].
final ListTileTitleAlignment? titleAlignment;
/// Whether to add button:true to the semantics if onTap is provided.
/// This is a temporary flag to help changing the behavior of ListTile onTap semantics.
///
// TODO(hangyujin): Remove this flag after fixing related g3 tests and flipping
// the default value to true.
final bool internalAddSemanticForOnTap;
/// Controls the scaling factor applied to the [Checkbox] within the [OrderedCheckboxListTile].
///
/// Defaults to 1.0.
final double checkboxScaleFactor;
@override
Widget build(BuildContext context) {
Widget control;
control = OrderedCheckbox(value: value, onChanged: null);
if (checkboxScaleFactor != 1.0) {
control = Transform.scale(scale: checkboxScaleFactor, child: control);
}
final ThemeData theme = Theme.of(context);
final CheckboxThemeData checkboxTheme = CheckboxTheme.of(context);
final Set<WidgetState> states = <WidgetState>{
if (value != null) WidgetState.selected,
};
final Color effectiveActiveColor =
activeColor ??
checkboxTheme.fillColor?.resolve(states) ??
theme.colorScheme.secondary;
return MergeSemantics(
child: ListTile(
selectedColor: effectiveActiveColor,
leading: control,
title: title,
subtitle: subtitle,
trailing: trailing,
isThreeLine: isThreeLine,
dense: dense,
enabled: onChanged != null,
onTap: onChanged != null ? () => onChanged!(value) : null,
selected: value != null,
autofocus: autofocus,
contentPadding: contentPadding,
shape: shape,
selectedTileColor: selectedTileColor,
tileColor: tileColor,
visualDensity: visualDensity,
focusNode: focusNode,
onFocusChange: onFocusChange,
enableFeedback: enableFeedback,
titleAlignment: titleAlignment,
internalAddSemanticForOnTap: internalAddSemanticForOnTap,
),
);
}
}

View File

@@ -33,12 +33,12 @@ class _MultiSelectDialogState<T> extends State<MultiSelectDialog<T>> {
clipBehavior: Clip.hardEdge,
title: Text(widget.title),
contentPadding: const EdgeInsets.only(top: 12),
content: StatefulBuilder(
builder: (context, StateSetter setState) {
return SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: widget.values.entries.map((i) {
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: widget.values.entries.map((i) {
return Builder(
builder: (context) {
bool isChecked = _tempValues.contains(i.key);
return CheckboxListTile(
dense: true,
@@ -52,13 +52,13 @@ class _MultiSelectDialogState<T> extends State<MultiSelectDialog<T>> {
isChecked
? _tempValues.remove(i.key)
: _tempValues.add(i.key);
setState(() {});
(context as Element).markNeedsBuild();
},
);
}).toList(),
),
);
},
},
);
}).toList(),
),
),
actionsPadding: const EdgeInsets.only(left: 16, right: 16, bottom: 12),
actions: [

View File

@@ -0,0 +1,96 @@
import 'package:PiliPlus/pages/setting/widgets/checkbox_num_list_tile.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
class OrderedMultiSelectDialog<T> extends StatefulWidget {
final Iterable<T> initValues;
final String title;
final Map<T, String> values;
const OrderedMultiSelectDialog({
super.key,
required this.initValues,
required this.values,
required this.title,
});
@override
State<OrderedMultiSelectDialog<T>> createState() =>
_OrderedMultiSelectDialogState<T>();
}
class _OrderedMultiSelectDialogState<T>
extends State<OrderedMultiSelectDialog<T>> {
late Map<T, int> _tempValues;
@override
void initState() {
super.initState();
_tempValues = {for (var (i, j) in widget.initValues.indexed) j: i + 1};
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return AlertDialog(
clipBehavior: Clip.hardEdge,
title: Text(widget.title),
contentPadding: const EdgeInsets.only(top: 12),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: widget.values.entries.map((i) {
return Builder(
builder: (context) {
return OrderedCheckboxListTile(
dense: true,
value: _tempValues[i.key],
title: Text(
i.value,
style: theme.textTheme.titleMedium!,
),
onChanged: (value) {
if (value == null) {
_tempValues[i.key] = _tempValues.length + 1;
(context as Element).markNeedsBuild();
} else {
final pos = _tempValues.remove(i.key)!;
if (pos == _tempValues.length + 1) {
(context as Element).markNeedsBuild();
} else {
_tempValues.updateAll(
(key, value) => value > pos ? value - 1 : value,
);
setState(() {});
}
}
},
);
},
);
}).toList(),
),
),
actionsPadding: const EdgeInsets.only(left: 16, right: 16, bottom: 12),
actions: [
TextButton(
onPressed: Get.back,
child: Text(
'取消',
style: TextStyle(
color: theme.colorScheme.outline,
),
),
),
TextButton(
onPressed: () {
assert(_tempValues.values.isSorted((a, b) => a.compareTo(b)));
Get.back(result: _tempValues.keys.toList());
},
child: const Text('确定'),
),
],
);
}
}

View File

@@ -47,7 +47,10 @@ class _SettingsSearchPageState
(item.title ?? item.getTitle!()).toLowerCase().contains(
value,
) ||
item.subtitle?.toLowerCase().contains(value) == true,
(item.subtitle ?? item.getSubtitle?.call())
?.toLowerCase()
.contains(value) ==
true,
)
.toList();
}

View File

@@ -648,71 +648,77 @@ class HeaderControlState extends TripleState<HeaderControl> {
clipBehavior: Clip.hardEdge,
color: theme.colorScheme.surface,
borderRadius: const BorderRadius.all(Radius.circular(12)),
child: ListView(
padding: EdgeInsets.zero,
children: [
SizedBox(
height: 45,
child: GestureDetector(
onTap: () => SmartDialog.showToast(
'标灰画质需要bilibili会员已是会员请关闭无痕模式4k和杜比视界播放效果可能不佳',
),
child: Row(
spacing: 8,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('选择画质', style: titleStyle),
Icon(
Icons.info_outline,
size: 16,
color: theme.colorScheme.outline,
),
],
child: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: SizedBox(
height: 45,
child: GestureDetector(
onTap: () => SmartDialog.showToast(
'标灰画质需要bilibili会员已是会员请关闭无痕模式4k和杜比视界播放效果可能不佳',
),
child: Row(
spacing: 8,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('选择画质', style: titleStyle),
Icon(
Icons.info_outline,
size: 16,
color: theme.colorScheme.outline,
),
],
),
),
),
),
...List.generate(totalQaSam, (index) {
final item = videoFormat[index];
return ListTile(
dense: true,
onTap: () async {
if (currentVideoQa.code == item.quality) {
return;
}
Get.back();
final int quality = item.quality!;
final newQa = VideoQuality.fromCode(quality);
videoDetailCtr
..currentVideoQa.value = newQa
..updatePlayer();
SliverList.builder(
itemCount: totalQaSam,
itemBuilder: (context, index) {
final item = videoFormat[index];
return ListTile(
dense: true,
onTap: () async {
if (currentVideoQa.code == item.quality) {
return;
}
Get.back();
final int quality = item.quality!;
final newQa = VideoQuality.fromCode(quality);
videoDetailCtr
..currentVideoQa.value = newQa
..updatePlayer();
SmartDialog.showToast("画质已变为:${newQa.desc}");
SmartDialog.showToast("画质已变为:${newQa.desc}");
// update
if (!plPlayerController.tempPlayerConf) {
setting.put(
await Utils.isWiFi
? SettingBoxKey.defaultVideoQa
: SettingBoxKey.defaultVideoQaCellular,
quality,
);
}
},
// 可能包含会员解锁画质
enabled: index >= totalQaSam - userfulQaSam,
contentPadding: const EdgeInsets.only(left: 20, right: 20),
title: Text(item.newDesc!),
trailing: currentVideoQa.code == item.quality
? Icon(
Icons.done,
color: theme.colorScheme.primary,
)
: Text(
item.format!,
style: subTitleStyle,
),
);
}),
// update
if (!plPlayerController.tempPlayerConf) {
setting.put(
await Utils.isWiFi
? SettingBoxKey.defaultVideoQa
: SettingBoxKey.defaultVideoQaCellular,
quality,
);
}
},
// 可能包含会员解锁画质
enabled: index >= totalQaSam - userfulQaSam,
contentPadding: const EdgeInsets.symmetric(
horizontal: 20,
),
title: Text(item.newDesc!),
trailing: currentVideoQa.code == item.quality
? Icon(
Icons.done,
color: theme.colorScheme.primary,
)
: Text(
item.format!,
style: subTitleStyle,
),
);
},
),
],
),
),
@@ -734,55 +740,62 @@ class HeaderControlState extends TripleState<HeaderControl> {
clipBehavior: Clip.hardEdge,
color: theme.colorScheme.surface,
borderRadius: const BorderRadius.all(Radius.circular(12)),
child: ListView(
padding: EdgeInsets.zero,
children: [
const SizedBox(
height: 45,
child: Center(
child: Text('选择音质', style: titleStyle),
child: CustomScrollView(
slivers: [
const SliverToBoxAdapter(
child: SizedBox(
height: 45,
child: Center(
child: Text('选择音质', style: titleStyle),
),
),
),
for (final AudioItem i in audio) ...[
ListTile(
dense: true,
onTap: () async {
if (currentAudioQa.code == i.id) {
return;
}
Get.back();
final int quality = i.id!;
final newQa = AudioQuality.fromCode(quality);
videoDetailCtr
..currentAudioQa = newQa
..updatePlayer();
SliverList.builder(
itemCount: audio.length,
itemBuilder: (context, index) {
final i = audio[index];
return ListTile(
dense: true,
onTap: () async {
if (currentAudioQa.code == i.id) {
return;
}
Get.back();
final int quality = i.id!;
final newQa = AudioQuality.fromCode(quality);
videoDetailCtr
..currentAudioQa = newQa
..updatePlayer();
SmartDialog.showToast("音质已变为:${newQa.desc}");
SmartDialog.showToast("音质已变为:${newQa.desc}");
// update
if (!plPlayerController.tempPlayerConf) {
setting.put(
await Utils.isWiFi
? SettingBoxKey.defaultAudioQa
: SettingBoxKey.defaultAudioQaCellular,
quality,
);
}
},
contentPadding: const EdgeInsets.only(left: 20, right: 20),
title: Text(i.quality),
subtitle: Text(
i.codecs!,
style: subTitleStyle,
),
trailing: currentAudioQa.code == i.id
? Icon(
Icons.done,
color: theme.colorScheme.primary,
)
: null,
),
],
// update
if (!plPlayerController.tempPlayerConf) {
setting.put(
await Utils.isWiFi
? SettingBoxKey.defaultAudioQa
: SettingBoxKey.defaultAudioQaCellular,
quality,
);
}
},
contentPadding: const EdgeInsets.symmetric(
horizontal: 20,
),
title: Text(i.quality),
subtitle: Text(
i.codecs!,
style: subTitleStyle,
),
trailing: currentAudioQa.code == i.id
? Icon(
Icons.done,
color: theme.colorScheme.primary,
)
: null,
);
},
),
],
),
),
@@ -825,41 +838,41 @@ class HeaderControlState extends TripleState<HeaderControl> {
),
),
Expanded(
child: ListView(
padding: EdgeInsets.zero,
children: [
for (var i in list) ...[
ListTile(
dense: true,
onTap: () {
if (currentDecodeFormats.codes.any(i.startsWith)) {
return;
}
videoDetailCtr
..currentDecodeFormats =
VideoDecodeFormatType.fromString(i)
..updatePlayer();
Get.back();
},
contentPadding: const EdgeInsets.only(
left: 20,
right: 20,
),
title: Text(
VideoDecodeFormatType.fromString(i).description,
),
subtitle: Text(
i,
style: subTitleStyle,
),
trailing: currentDecodeFormats.codes.any(i.startsWith)
? Icon(
Icons.done,
color: theme.colorScheme.primary,
)
: null,
),
],
child: CustomScrollView(
slivers: [
SliverList.builder(
itemCount: list.length,
itemBuilder: (context, index) {
final i = list[index];
final format = VideoDecodeFormatType.fromString(i);
return ListTile(
dense: true,
onTap: () {
if (currentDecodeFormats.codes.any(
i.startsWith,
)) {
return;
}
videoDetailCtr
..currentDecodeFormats = format
..updatePlayer();
Get.back();
},
contentPadding: const EdgeInsets.symmetric(
horizontal: 20,
),
title: Text(format.description),
subtitle: Text(i, style: subTitleStyle),
trailing:
currentDecodeFormats.codes.any(i.startsWith)
? Icon(
Icons.done,
color: theme.colorScheme.primary,
)
: null,
);
},
),
],
),
),
@@ -1845,32 +1858,39 @@ class HeaderControlState extends TripleState<HeaderControl> {
clipBehavior: Clip.hardEdge,
color: theme.colorScheme.surface,
borderRadius: const BorderRadius.all(Radius.circular(12)),
child: ListView(
padding: EdgeInsets.zero,
children: [
const SizedBox(
height: 45,
child: Center(
child: Text('选择播放顺序', style: titleStyle),
child: CustomScrollView(
slivers: [
const SliverToBoxAdapter(
child: SizedBox(
height: 45,
child: Center(
child: Text('选择播放顺序', style: titleStyle),
),
),
),
for (final PlayRepeat i in PlayRepeat.values) ...[
ListTile(
dense: true,
onTap: () {
plPlayerController.setPlayRepeat(i);
Get.back();
},
contentPadding: const EdgeInsets.only(left: 20, right: 20),
title: Text(i.desc),
trailing: plPlayerController.playRepeat == i
? Icon(
Icons.done,
color: theme.colorScheme.primary,
)
: null,
),
],
SliverList.builder(
itemCount: PlayRepeat.values.length,
itemBuilder: (context, index) {
final i = PlayRepeat.values[index];
return ListTile(
dense: true,
onTap: () {
plPlayerController.setPlayRepeat(i);
Get.back();
},
contentPadding: const EdgeInsets.symmetric(
horizontal: 20,
),
title: Text(i.desc),
trailing: plPlayerController.playRepeat == i
? Icon(
Icons.done,
color: theme.colorScheme.primary,
)
: null,
);
},
),
],
),
),