6238b86dcb
- 登录/注册(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
302 lines
9.8 KiB
Dart
302 lines
9.8 KiB
Dart
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
|
import 'package:flutter/material.dart';
|
|
|
|
import '../core/theme/context_tokens.dart';
|
|
import '../core/theme/app_dims.g.dart';
|
|
import 'searchable_option_field.dart' show OptionItem;
|
|
import '../core/theme/app_fonts.dart';
|
|
|
|
/// 搜索下拉框(对齐原型 .combo + .combo-pop):
|
|
/// 触发器(带框 input 样式 + caret/清除)+ 锚定在下方的弹层
|
|
/// (顶部搜索框 + 名称/编码两列 + 选中蓝勾 + 底部「共 N 条结果」)。
|
|
///
|
|
/// 与 SearchableOptionField(居中 AlertDialog)不同——这是**锚定弹层**,
|
|
/// 用于列表工具栏「供应商/客户」等就地筛选。
|
|
class ComboSearchField extends StatefulWidget {
|
|
final List<OptionItem> options;
|
|
final int? selectedId;
|
|
final String hint;
|
|
final ValueChanged<int?> onChanged;
|
|
|
|
/// 固定宽度;传 null 则填满父约束(用于弹窗内 Expanded 布局)。
|
|
final double? width;
|
|
|
|
/// 触发器高度(工具栏 34,表单/弹窗 38,对齐原型 .combo)。
|
|
final double height;
|
|
|
|
/// 触发器填充色;null=透明(工具栏用,透出底色)。弹窗内传 surface 保持不透明一致。
|
|
final Color? fillColor;
|
|
|
|
const ComboSearchField({
|
|
super.key,
|
|
required this.options,
|
|
required this.selectedId,
|
|
required this.hint,
|
|
required this.onChanged,
|
|
this.width,
|
|
this.height = 34,
|
|
this.fillColor,
|
|
});
|
|
|
|
@override
|
|
State<ComboSearchField> createState() => _ComboSearchFieldState();
|
|
}
|
|
|
|
class _ComboSearchFieldState extends State<ComboSearchField> {
|
|
final _link = LayerLink();
|
|
OverlayEntry? _entry;
|
|
|
|
OptionItem? get _selected {
|
|
for (final o in widget.options) {
|
|
if (o.id == widget.selectedId) return o;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_remove();
|
|
super.dispose();
|
|
}
|
|
|
|
void _remove() {
|
|
_entry?.remove();
|
|
_entry = null;
|
|
}
|
|
|
|
void _open() {
|
|
if (_entry != null) return;
|
|
_entry = OverlayEntry(builder: (_) => _buildPopup());
|
|
Overlay.of(context).insert(_entry!);
|
|
}
|
|
|
|
void _pick(int? id) {
|
|
widget.onChanged(id);
|
|
_remove();
|
|
}
|
|
|
|
Widget _buildPopup() {
|
|
final t = context.tokens;
|
|
return Stack(
|
|
children: [
|
|
// 外部点击关闭
|
|
Positioned.fill(
|
|
child: GestureDetector(
|
|
behavior: HitTestBehavior.translucent,
|
|
onTap: _remove,
|
|
),
|
|
),
|
|
CompositedTransformFollower(
|
|
link: _link,
|
|
showWhenUnlinked: false,
|
|
targetAnchor: Alignment.bottomLeft,
|
|
followerAnchor: Alignment.topLeft,
|
|
offset: const Offset(0, 6),
|
|
child: Align(
|
|
alignment: Alignment.topLeft,
|
|
child: Material(
|
|
elevation: 8,
|
|
borderRadius: BorderRadius.circular(AppDims.rMd),
|
|
color: t.surface,
|
|
child: ConstrainedBox(
|
|
constraints: BoxConstraints(
|
|
minWidth: (widget.width == null || widget.width! < 240)
|
|
? 240
|
|
: widget.width!,
|
|
maxWidth: 320),
|
|
child: _ComboPop(
|
|
options: widget.options,
|
|
selectedId: widget.selectedId,
|
|
hint: widget.hint,
|
|
onPick: _pick,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final t = context.tokens;
|
|
final sel = _selected;
|
|
return CompositedTransformTarget(
|
|
link: _link,
|
|
child: SizedBox(
|
|
width: widget.width, // null → 由父约束决定(Expanded 内填满)
|
|
height: widget.height,
|
|
child: InkWell(
|
|
borderRadius: BorderRadius.circular(AppDims.rMd),
|
|
onTap: _open,
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 11),
|
|
decoration: BoxDecoration(
|
|
color: widget.fillColor,
|
|
border: Border.all(color: t.border),
|
|
borderRadius: BorderRadius.circular(AppDims.rMd),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
sel?.name ?? widget.hint,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontSize: AppDims.fsBody,
|
|
color: sel == null ? t.faint : t.text,
|
|
),
|
|
),
|
|
),
|
|
if (sel != null)
|
|
GestureDetector(
|
|
onTap: () => _pick(null),
|
|
child: Icon(LucideIcons.x, size: 14, color: t.muted),
|
|
)
|
|
else
|
|
Icon(LucideIcons.chevronDown, size: 16, color: t.faint),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 弹层内容:搜索框 + 列表(名称 + 编码 + 选中勾)+ 底部计数。
|
|
class _ComboPop extends StatefulWidget {
|
|
final List<OptionItem> options;
|
|
final int? selectedId;
|
|
final String hint;
|
|
final ValueChanged<int?> onPick;
|
|
const _ComboPop({
|
|
required this.options,
|
|
required this.selectedId,
|
|
required this.hint,
|
|
required this.onPick,
|
|
});
|
|
|
|
@override
|
|
State<_ComboPop> createState() => _ComboPopState();
|
|
}
|
|
|
|
class _ComboPopState extends State<_ComboPop> {
|
|
String _kw = '';
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final t = context.tokens;
|
|
final q = _kw.trim().toLowerCase();
|
|
final hits = q.isEmpty
|
|
? widget.options
|
|
: widget.options.where((o) => o.matches(_kw)).toList();
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
// 顶部搜索框
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 9),
|
|
decoration: BoxDecoration(
|
|
border: Border(bottom: BorderSide(color: t.borderSubtle)),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(LucideIcons.search, size: 14, color: t.faint),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: TextField(
|
|
autofocus: true,
|
|
style: TextStyle(fontSize: AppDims.fsBody, color: t.text),
|
|
decoration: InputDecoration(
|
|
isCollapsed: true,
|
|
contentPadding: EdgeInsets.zero,
|
|
border: InputBorder.none,
|
|
filled: false,
|
|
enabledBorder: InputBorder.none,
|
|
focusedBorder: InputBorder.none,
|
|
hintText: '搜索${widget.hint}…',
|
|
hintStyle:
|
|
TextStyle(fontSize: AppDims.fsBody, color: t.faint),
|
|
),
|
|
onChanged: (v) => setState(() => _kw = v),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
// 列表
|
|
ConstrainedBox(
|
|
constraints: const BoxConstraints(maxHeight: 264),
|
|
child: hits.isEmpty
|
|
? Padding(
|
|
padding: const EdgeInsets.all(20),
|
|
child: Text('无匹配结果',
|
|
style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)))
|
|
: ListView.builder(
|
|
padding: const EdgeInsets.all(5),
|
|
shrinkWrap: true,
|
|
itemCount: hits.length,
|
|
itemBuilder: (_, i) {
|
|
final o = hits[i];
|
|
final sel = o.id == widget.selectedId;
|
|
return InkWell(
|
|
borderRadius: BorderRadius.circular(AppDims.rSm),
|
|
onTap: () => widget.onPick(o.id),
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 10, vertical: 8),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(o.name,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontSize: AppDims.fsBody,
|
|
color: sel ? t.primary : t.text,
|
|
fontWeight: sel
|
|
? FontWeight.w600
|
|
: FontWeight.w400)),
|
|
),
|
|
if (o.code != null && o.code!.isNotEmpty) ...[
|
|
const SizedBox(width: 12),
|
|
Text(o.code!,
|
|
style: TextStyle(
|
|
fontSize: AppDims.fsXs,
|
|
fontFamily: AppFonts.mono,
|
|
fontFamilyFallback: AppFonts.monoFallback,
|
|
color: t.faint)),
|
|
],
|
|
if (sel) ...[
|
|
const SizedBox(width: 8),
|
|
Icon(LucideIcons.check,
|
|
size: 15, color: t.primary),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
// 底部计数
|
|
Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7),
|
|
decoration: BoxDecoration(
|
|
color: t.bg,
|
|
border: Border(top: BorderSide(color: t.borderSubtle)),
|
|
),
|
|
child: Text(
|
|
q.isEmpty
|
|
? '共 ${widget.options.length} 条结果'
|
|
: '匹配 ${hits.length} 条 · 共 ${widget.options.length} 条',
|
|
style: TextStyle(fontSize: AppDims.fsXs, color: t.muted),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|