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 options; final int? selectedId; final String hint; final ValueChanged 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 createState() => _ComboSearchFieldState(); } class _ComboSearchFieldState extends State { 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 options; final int? selectedId; final String hint; final ValueChanged 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), ), ), ], ); } }