Files
jiu/client/lib/widgets/multi_select_dropdown.dart
T
wangjia 6238b86dcb feat(client): 登录/注册页照原型重建,ds 真相源组件族统一全部屏
- 登录/注册(login.html/register.html 1:1):两栏卡片+品牌渐变面板+主题小衣服
  pill(onSurface 变体)+记住我(记录并预填最近账号)+原型式 toast 校验;
  登录 fidelity 1.4–2.1% 三主题全绿;注册暂不入闸(少 门店编号/兑换券 字段,
  已记 CONTRACT,screens.mjs 留存根)
- ds 原子补齐:DsToast(.toast 单例)/DsCheck(.check/.agree)/DsButton lg 档/
  DsSelect 替换全部旧 DropdownButton/DsField label 在上/DsInput 后缀与密码形态
- 全屏统一:对话框按钮全 DsButton、盒式输入主题钉死(visualDensity.standard、
  h38、InputDecorationTheme 渗漏修复)、图标全 lucide、JetBrains Mono 三端同源、
  BrandMark 真相源 logo、只读模式写操作全量守卫(WriteGuard+DsToast)
- 出入库列表:版式对齐原型(卡片对齐+搜索框居中)、KPI 近30天滚动、结清后
  失效财务应收应付表;商品编辑抽屉介绍库改搜索下拉、图片双击全屏预览
- 删除旧 UI 死代码:DataTableCard/FormDialog/PageScaffold/SearchChip/
  SelectProductDialog/tabStateProvider
- golden/fidelity:stock-in/out 补注册(9%)、login 入册(8%)、goldens 全量重打;
  修复 pubspec flutter_web_plugins 非法声明(CI pub get 阻断)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-03 09:58:14 +08:00

430 lines
14 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../core/utils/dialog_util.dart';
import 'package:flutter/material.dart';
import '../core/theme/context_tokens.dart';
import 'ds/ds_atoms.dart';
/// Compact button → dialog with checkboxes for multi-select filtering
class MultiSelectDropdown extends StatelessWidget {
final String label;
final List<String> options;
final Set<String> selected;
final ValueChanged<Set<String>> onChanged;
const MultiSelectDropdown({
super.key,
required this.label,
required this.options,
required this.selected,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
final active = selected.isNotEmpty;
return OutlinedButton(
onPressed: options.isEmpty ? null : () => _show(context),
style: OutlinedButton.styleFrom(
foregroundColor: active ? context.tokens.primary : context.tokens.muted,
side: BorderSide(
color: active ? context.tokens.primary : context.tokens.border),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(LucideIcons.listFilter, size: 13),
const SizedBox(width: 4),
Text(
active ? '$label (${selected.length})' : label,
style: const TextStyle(fontSize: 12),
),
if (active) ...[
const SizedBox(width: 4),
GestureDetector(
onTap: () => onChanged({}),
child: const Icon(LucideIcons.x, size: 12),
),
],
],
),
);
}
void _show(BuildContext context) {
showAppDialog(
context: context,
barrierColor: Colors.black12, // ds-ignore: 弹层遮罩固定色(.mask
builder: (_) => _MultiSelectDialog(
label: label,
options: options,
initial: selected,
onApply: onChanged,
),
);
}
}
class _MultiSelectDialog extends StatefulWidget {
final String label;
final List<String> options;
final Set<String> initial;
final ValueChanged<Set<String>> onApply;
const _MultiSelectDialog({
required this.label,
required this.options,
required this.initial,
required this.onApply,
});
@override
State<_MultiSelectDialog> createState() => _MultiSelectDialogState();
}
class _MultiSelectDialogState extends State<_MultiSelectDialog> {
late Set<String> _selected;
final _searchCtrl = TextEditingController();
String _search = '';
@override
void initState() {
super.initState();
_selected = Set.from(widget.initial);
}
@override
void dispose() {
_searchCtrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final filtered = _search.isEmpty
? widget.options
: widget.options
.where((o) => o.toLowerCase().contains(_search.toLowerCase()))
.toList();
return AlertDialog(
title: Text(widget.label, style: const TextStyle(fontSize: 15)),
contentPadding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
content: SizedBox(
width: 280,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 搜索框
TextField(
controller: _searchCtrl,
autofocus: true,
decoration: const InputDecoration(
hintText: '搜索…',
prefixIcon: Icon(LucideIcons.search, size: 16),
isDense: true,
contentPadding: EdgeInsets.symmetric(vertical: 8),
),
style: const TextStyle(fontSize: 13),
onChanged: (v) => setState(() => _search = v),
),
const SizedBox(height: 8),
// 已选标签区
if (_selected.isNotEmpty)
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 96),
child: SingleChildScrollView(
child: Wrap(
spacing: 6,
runSpacing: 4,
children: _selected
.map((s) => Chip(
label:
Text(s, style: const TextStyle(fontSize: 12)),
deleteIcon: const Icon(LucideIcons.x, size: 14),
onDeleted: () =>
setState(() => _selected.remove(s)),
materialTapTargetSize:
MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
backgroundColor:
context.tokens.primary.withOpacity(0.08),
side: BorderSide(
color:
context.tokens.primary.withOpacity(0.3),
width: 0.5),
labelPadding:
const EdgeInsets.symmetric(horizontal: 2),
padding: const EdgeInsets.symmetric(
horizontal: 4, vertical: 0),
))
.toList(),
),
),
),
if (_selected.isNotEmpty) const SizedBox(height: 6),
// 全选 / 清空
Row(children: [
TextButton(
onPressed: () => setState(() => _selected.addAll(filtered)),
child: const Text('全选', style: TextStyle(fontSize: 12)),
),
TextButton(
onPressed: () => setState(() => _selected.clear()),
child: const Text('清空', style: TextStyle(fontSize: 12)),
),
]),
const Divider(height: 1),
// 选项列表(有约束 + 滚动)
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 280),
child: filtered.isEmpty
? Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Center(
child: Text('无匹配项',
style: TextStyle(
fontSize: 13, color: context.tokens.muted)),
),
)
: ListView(
shrinkWrap: true,
children: filtered
.map((opt) => CheckboxListTile(
title: Text(opt,
style: const TextStyle(fontSize: 13)),
value: _selected.contains(opt),
dense: true,
onChanged: (v) => setState(() {
if (v == true) {
_selected.add(opt);
} else {
_selected.remove(opt);
}
}),
))
.toList(),
),
),
],
),
),
actions: [
DsButton('取消', onPressed: () => Navigator.of(context).pop()),
DsButton('应用', variant: DsBtnVariant.primary, onPressed: () {
widget.onApply(_selected);
Navigator.of(context).pop();
}),
],
);
}
}
/// Column header widget that shows a filter icon on hover.
/// Clicking the icon opens the same multi-select dialog as [MultiSelectDropdown].
/// Use as [DataColumn.label] for filterable columns.
class FilterableColumnHeader extends StatefulWidget {
final String text;
final List<String> options;
final Set<String> selected;
final ValueChanged<Set<String>> onChanged;
const FilterableColumnHeader({
super.key,
required this.text,
required this.options,
required this.selected,
required this.onChanged,
});
@override
State<FilterableColumnHeader> createState() => _FilterableColumnHeaderState();
}
class _FilterableColumnHeaderState extends State<FilterableColumnHeader> {
@override
Widget build(BuildContext context) {
final active = widget.selected.isNotEmpty;
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(widget.text),
Row(
mainAxisSize: MainAxisSize.min,
children: [
InkWell(
onTap: widget.options.isEmpty ? null : () => _show(context),
borderRadius: BorderRadius.circular(4),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 3),
child: Icon(
active ? LucideIcons.funnel : LucideIcons.funnel,
size: 16,
color: active ? context.tokens.primary : context.tokens.muted,
),
),
),
if (active)
InkWell(
onTap: () => widget.onChanged({}),
borderRadius: BorderRadius.circular(4),
child: Icon(LucideIcons.circleX,
size: 14, color: context.tokens.primary),
),
],
),
],
);
}
void _show(BuildContext context) {
showAppDialog(
context: context,
barrierColor: Colors.black12, // ds-ignore: 弹层遮罩固定色(.mask
builder: (_) => _MultiSelectDialog(
label: widget.text,
options: widget.options,
initial: widget.selected,
onApply: widget.onChanged,
),
);
}
}
/// Column definition for column visibility toggle
class ColDef {
final String key;
final String label;
final bool required; // if true, cannot be hidden
/// Screen width below which this column is automatically hidden
final double? minWidth;
const ColDef(this.key, this.label, {this.required = false, this.minWidth});
}
/// Button that lets users toggle column visibility
class ColumnToggleButton extends StatelessWidget {
final List<ColDef> columns;
final Set<String> hidden;
final ValueChanged<Set<String>> onChanged;
/// 紧凑模式:只显示图标(移动端用,省空间)。
final bool compact;
const ColumnToggleButton({
super.key,
required this.columns,
required this.hidden,
required this.onChanged,
this.compact = false,
});
@override
Widget build(BuildContext context) {
if (compact) {
return IconButton(
tooltip: '显示字段',
icon: const Icon(LucideIcons.columns3, size: 20),
onPressed: () => _show(context),
);
}
return OutlinedButton(
onPressed: () => _show(context),
style: OutlinedButton.styleFrom(
foregroundColor: context.tokens.muted,
side: BorderSide(color: context.tokens.border),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(LucideIcons.columns3, size: 13),
SizedBox(width: 4),
Text('显示字段', style: TextStyle(fontSize: 12)),
],
),
);
}
void _show(BuildContext context) {
showAppDialog(
context: context,
barrierColor: Colors.black12, // ds-ignore: 弹层遮罩固定色(.mask
builder: (_) => _ColumnToggleDialog(
columns: columns,
initial: hidden,
onApply: onChanged,
),
);
}
}
class _ColumnToggleDialog extends StatefulWidget {
final List<ColDef> columns;
final Set<String> initial;
final ValueChanged<Set<String>> onApply;
const _ColumnToggleDialog({
required this.columns,
required this.initial,
required this.onApply,
});
@override
State<_ColumnToggleDialog> createState() => _ColumnToggleDialogState();
}
class _ColumnToggleDialogState extends State<_ColumnToggleDialog> {
late Set<String> _hidden;
@override
void initState() {
super.initState();
_hidden = Set.from(widget.initial);
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('显示字段', style: TextStyle(fontSize: 15)),
contentPadding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
content: SizedBox(
width: 220,
child: Column(
mainAxisSize: MainAxisSize.min,
children: widget.columns
.map((col) => CheckboxListTile(
title:
Text(col.label, style: const TextStyle(fontSize: 13)),
value: !_hidden.contains(col.key),
dense: true,
onChanged: col.required
? null // required columns cannot be hidden
: (v) => setState(() {
if (v == true) {
_hidden.remove(col.key);
} else {
_hidden.add(col.key);
}
}),
))
.toList(),
),
),
actions: [
DsButton('取消', onPressed: () => Navigator.of(context).pop()),
DsButton('应用', variant: DsBtnVariant.primary, onPressed: () {
widget.onApply(_hidden);
Navigator.of(context).pop();
}),
],
);
}
}