import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../../core/theme/app_dims.g.dart'; import '../../core/theme/app_tokens.dart'; import '../../core/theme/context_tokens.dart'; import '../searchable_option_field.dart' show OptionItem; import '../../core/theme/app_fonts.dart'; /// 内联可键盘操作的下拉选择格(对齐原型 `.combo` / `.combo.cell` + `.combo-pop`)。 /// /// - 聚焦即打开浮层;输入即过滤(名称 / 全拼 / 首字母 / 编码)。 /// - ↑↓ 移动高亮,Enter 选中并回调 [onPickedByKeyboard](供上层推进到下一格), /// Esc 关闭,Tab 交由上层 [onTab] 决定前进/后退。 /// - [allowCreate] 时,输入无匹配可「新增到基础数据」,走 [onCreate]。 /// - [commonIds] 命中的选项在无关键字时置顶为「常用」组(对齐系列/规格默认)。 class GridComboCell extends StatefulWidget { final FocusNode focusNode; final List options; final int? selectedId; final String hint; /// true=网格单元格样式(透明、32 高);false=独立表单 combo(描边、38 高)。 final bool cell; /// 浮层是否置顶持久搜索框(对齐原型 .combo-pop > .cp-search,触发框只读展示)。 /// null=沿用默认(单据头 combo 用弹层搜索、网格 combo 用单元格内联输入); /// 传 true 可让网格单元格也走弹层搜索(如入库明细的商品名/系列/规格)。 final bool? popupSearch; final bool hasError; final bool enabled; final bool allowCreate; /// 无关键字时置顶显示的「常用」选项 id(如按商品名带出的默认系列/规格)。 final Set commonIds; /// 选项数、供应商/客户等大字典时置底显示计数(对齐原型单据头 combo 的 cp-foot)。 final bool showCount; /// 单据头 combo(cell=false)浮层顶部 .cp-search 的占位文案;空则回退到 [hint]。 final String? searchHint; /// 服务端搜索(供供应商/客户/库存商品等);提供时输入 debounce 调它取结果。 final Future> Function(String keyword)? onSearch; /// 新增到基础数据;返回新选项 id(自动选中)。 final Future Function(String keyword)? onCreate; final void Function(int? id) onChanged; /// 通过键盘 Enter 选中后触发(上层据此推进到下一格 / 加行)。 final VoidCallback? onPickedByKeyboard; /// Tab(shift=后退);返回 true 表示已处理。 final bool Function({required bool backward})? onTab; const GridComboCell({ super.key, required this.focusNode, required this.options, required this.selectedId, required this.hint, required this.onChanged, this.cell = false, this.popupSearch, this.hasError = false, this.enabled = true, this.allowCreate = false, this.commonIds = const {}, this.showCount = false, this.searchHint, this.onSearch, this.onCreate, this.onPickedByKeyboard, this.onTab, }); @override State createState() => _GridComboCellState(); } /// 浮层里可高亮/选中的一项:既有选项,或「新增」行。 class _PopEntry { final OptionItem? opt; // null → 新增行 final String? createKw; const _PopEntry.opt(this.opt) : createKw = null; const _PopEntry.create(this.createKw) : opt = null; bool get isCreate => opt == null; } class _GridComboCellState extends State { final _ctrl = TextEditingController(); final _link = LayerLink(); final _listScroll = ScrollController(); // 单据头 combo:浮层顶部持久搜索框的焦点(网格 combo 不用,走单元格内联输入)。 final _searchFocus = FocusNode(); OverlayEntry? _entry; Timer? _debounce; /// 弹层搜索模式:浮层置顶显式搜索框,触发框保持只读展示(对齐原型 scope==='doc'); /// 反之走网格单元格内联输入过滤。默认单据头(cell=false)开、网格(cell=true)关, /// 可由 [GridComboCell.popupSearch] 显式覆盖。 bool get _docHead => widget.popupSearch ?? !widget.cell; bool _open = false; String _kw = ''; int _cursor = -1; List<_PopEntry> _entries = const []; List? _remote; // onSearch 结果;null=用本地 options @override void initState() { super.initState(); widget.focusNode.addListener(_onFocusChange); } @override void didUpdateWidget(GridComboCell old) { super.didUpdateWidget(old); if (old.focusNode != widget.focusNode) { old.focusNode.removeListener(_onFocusChange); widget.focusNode.addListener(_onFocusChange); } if (_open) _rebuildEntries(); } @override void dispose() { _debounce?.cancel(); widget.focusNode.removeListener(_onFocusChange); _removeOverlay(); _ctrl.dispose(); _searchFocus.dispose(); _listScroll.dispose(); super.dispose(); } String get _selectedName => widget.options .where((o) => o.id == widget.selectedId) .firstOrNull ?.name ?? ''; void _onFocusChange() { if (widget.focusNode.hasFocus) { _openPop(); if (_docHead) { // 触发框获焦即打开浮层,随后把输入焦点移进浮层顶部搜索框。 WidgetsBinding.instance.addPostFrameCallback((_) { if (_open && mounted) _searchFocus.requestFocus(); }); } } else if (!_docHead) { // 网格 combo:单元格失焦即收起。弹层搜索 combo 的收起改由 选中 / Esc / Tab / // 点浮层外(TapRegion.onTapOutside)显式驱动——不再监听搜索框失焦, // 避免点选项时「按下即失焦→微任务提前拆浮层→onTap 落空」的竞态(选中不填充)。 _closePop(); } } void _dismiss() { _searchFocus.unfocus(); widget.focusNode.unfocus(); _closePop(); } // ── 浮层开合 ──────────────────────────────────────────────────────────── void _openPop() { if (_open || !widget.enabled) return; _open = true; _kw = ''; _remote = null; _ctrl.text = ''; _rebuildEntries(); _entry = OverlayEntry(builder: _buildPop); Overlay.of(context).insert(_entry!); } void _closePop() { if (!_open) return; _open = false; _debounce?.cancel(); _ctrl.text = ''; _removeOverlay(); if (mounted) setState(() {}); } void _removeOverlay() { _entry?.remove(); _entry = null; } List get _sourceOptions => _remote ?? widget.options; void _rebuildEntries() { final kw = _kw.trim(); final matched = kw.isEmpty ? _sourceOptions : _sourceOptions.where((o) => o.matches(kw)).toList(); final list = <_PopEntry>[]; for (final o in matched) { list.add(_PopEntry.opt(o)); } final canCreate = widget.allowCreate && widget.onCreate != null && kw.isNotEmpty && !_sourceOptions.any((o) => o.name == kw); if (canCreate) list.add(_PopEntry.create(kw)); _entries = list; // 当前高亮:选中项优先,否则首项 _cursor = list.indexWhere((e) => !e.isCreate && e.opt!.id == widget.selectedId); if (_cursor < 0) _cursor = list.isEmpty ? -1 : 0; _entry?.markNeedsBuild(); } void _onInput(String v) { _kw = v; if (widget.onSearch != null) { _debounce?.cancel(); _debounce = Timer(const Duration(milliseconds: 250), () async { final res = await widget.onSearch!(v.trim()); if (!mounted || !_open) return; _remote = res; _rebuildEntries(); }); // 本地即时反馈(用已有 options 过滤) _rebuildEntries(); } else { _rebuildEntries(); } } void _move(int d) { if (_entries.isEmpty) return; _cursor = (_cursor + d + _entries.length) % _entries.length; _entry?.markNeedsBuild(); _scrollToCursor(); } void _scrollToCursor() { if (!_listScroll.hasClients || _cursor < 0) return; const itemH = 36.0; final target = _cursor * itemH; final vp = _listScroll.position.viewportDimension; final off = _listScroll.offset; if (target < off) { _listScroll.jumpTo(target); } else if (target + itemH > off + vp) { _listScroll.jumpTo( (target + itemH - vp).clamp(0, _listScroll.position.maxScrollExtent)); } } Future _pickCursor({required bool byKeyboard}) async { if (_cursor < 0 || _cursor >= _entries.length) return; final e = _entries[_cursor]; if (e.isCreate) { final id = await widget.onCreate!(e.createKw!); if (id != null) { widget.onChanged(id); if (byKeyboard) widget.onPickedByKeyboard?.call(); } _dismiss(); return; } widget.onChanged(e.opt!.id); _dismiss(); if (byKeyboard) widget.onPickedByKeyboard?.call(); } KeyEventResult _onKey(FocusNode node, KeyEvent event) { if (event is! KeyDownEvent && event is! KeyRepeatEvent) { return KeyEventResult.ignored; } final key = event.logicalKey; if (!_open) { if (key == LogicalKeyboardKey.enter || key == LogicalKeyboardKey.arrowDown) { _openPop(); return KeyEventResult.handled; } return KeyEventResult.ignored; } if (key == LogicalKeyboardKey.arrowDown) { _move(1); return KeyEventResult.handled; } if (key == LogicalKeyboardKey.arrowUp) { _move(-1); return KeyEventResult.handled; } if (key == LogicalKeyboardKey.enter || key == LogicalKeyboardKey.numpadEnter) { _pickCursor(byKeyboard: true); return KeyEventResult.handled; } if (key == LogicalKeyboardKey.escape) { _dismiss(); return KeyEventResult.handled; } if (key == LogicalKeyboardKey.tab) { final backward = HardwareKeyboard.instance.isShiftPressed; // 先按当前高亮选中(若有),再收起本浮层并交给上层移动焦点。 // 收起必须显式做:已移除搜索框失焦监听,Tab 走后不再自动关闭。 if (_cursor >= 0 && !_entries[_cursor].isCreate) { widget.onChanged(_entries[_cursor].opt!.id); } _closePop(); final handled = widget.onTab?.call(backward: backward) ?? false; return handled ? KeyEventResult.handled : KeyEventResult.ignored; } return KeyEventResult.ignored; } void _clear() { widget.onChanged(null); } // ── 浮层内容 ─────────────────────────────────────────────────────────── Widget _buildPop(BuildContext ctx) { final t = context.tokens; final field = context.findRenderObject() as RenderBox?; final width = (field?.size.width ?? 200).clamp(200.0, 420.0); return Positioned( width: width, child: CompositedTransformFollower( link: _link, showWhenUnlinked: false, targetAnchor: Alignment.bottomLeft, followerAnchor: Alignment.topLeft, offset: const Offset(0, 4), child: TapRegion( onTapOutside: (_) => _dismiss(), child: Material( elevation: 6, borderRadius: BorderRadius.circular(AppDims.rMd), color: t.surface, child: Container( decoration: BoxDecoration( border: Border.all(color: t.border), borderRadius: BorderRadius.circular(AppDims.rMd), ), clipBehavior: Clip.antiAlias, child: Column( mainAxisSize: MainAxisSize.min, children: [ if (_docHead) _buildSearch(t), Flexible(child: _buildList(t)), if (widget.showCount) _buildCount(t), ], ), ), ), ), ), ); } // 浮层顶部持久搜索框(对齐原型 .combo-pop > .cp-search,仅单据头 combo)。 Widget _buildSearch(AppTokens t) { final hint = widget.searchHint ?? widget.hint; return Container( padding: const EdgeInsets.fromLTRB(11, 9, 11, 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: Focus( canRequestFocus: false, onKeyEvent: _onKey, child: TextField( controller: _ctrl, focusNode: _searchFocus, autofocus: true, style: TextStyle(fontSize: AppDims.fsBody, color: t.text), cursorColor: t.primary, onChanged: _onInput, onSubmitted: (_) => _pickCursor(byKeyboard: true), decoration: InputDecoration( isCollapsed: true, contentPadding: EdgeInsets.zero, border: InputBorder.none, filled: false, enabledBorder: InputBorder.none, focusedBorder: InputBorder.none, hintText: hint, hintStyle: TextStyle(fontSize: AppDims.fsBody, color: t.faint), ), ), ), ), ], ), ); } Widget _buildList(AppTokens t) { if (_entries.isEmpty) { return Padding( padding: const EdgeInsets.all(20), child: Text('无匹配结果', textAlign: TextAlign.center, style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)), ); } final kw = _kw.trim(); final commonMode = kw.isEmpty && widget.commonIds.isNotEmpty; return ConstrainedBox( constraints: const BoxConstraints(maxHeight: 264), child: ListView.builder( controller: _listScroll, padding: const EdgeInsets.all(5), shrinkWrap: true, itemCount: _entries.length + (commonMode ? _groupHeaderCount() : 0), itemBuilder: (c, i) => _rowAt(t, i, commonMode, kw), ), ); } // 「常用 / 全部」分组时插入两个 header,索引需换算。 int _commonCount() => _entries .where((e) => !e.isCreate && widget.commonIds.contains(e.opt!.id)) .length; int _groupHeaderCount() { final c = _commonCount(); final r = _entries.where((e) => !e.isCreate).length - c; return (c > 0 ? 1 : 0) + (r > 0 ? 1 : 0); } Widget _rowAt(AppTokens t, int i, bool commonMode, String kw) { if (!commonMode) return _entryRow(t, i, kw); // 分组渲染:常用组 header + 常用项,全部组 header + 其余项 final common = []; final rest = []; for (var k = 0; k < _entries.length; k++) { final e = _entries[k]; if (e.isCreate) { rest.add(k); } else if (widget.commonIds.contains(e.opt!.id)) { common.add(k); } else { rest.add(k); } } final seq = []; if (common.isNotEmpty) { seq.add(() => _groupHeader(t, '常用')); for (final k in common) { seq.add(() => _entryRow(t, k, kw)); } } if (rest.isNotEmpty) { seq.add(() => _groupHeader(t, '全部')); for (final k in rest) { seq.add(() => _entryRow(t, k, kw)); } } return seq[i](); } Widget _groupHeader(AppTokens t, String label) => Padding( padding: const EdgeInsets.fromLTRB(9, 8, 9, 4), child: Text(label, style: TextStyle( fontSize: AppDims.fsXs, color: t.faint, fontWeight: FontWeight.w600, letterSpacing: 0.5)), ); Widget _entryRow(AppTokens t, int idx, String kw) { final e = _entries[idx]; final cur = idx == _cursor; if (e.isCreate) { return _PopRow( cur: cur, onTap: () => _pickCursor(byKeyboard: false), onHover: () => _setCursor(idx), topBorder: t.borderSubtle, child: Row(children: [ Icon(LucideIcons.plus, size: 14, color: t.primary), const SizedBox(width: 8), Expanded( child: Text('新增「${e.createKw}」到基础数据', overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: AppDims.fsSm, color: t.primary, fontWeight: FontWeight.w600)), ), ]), ); } final o = e.opt!; final sel = o.id == widget.selectedId; return _PopRow( cur: cur, onTap: () { _setCursor(idx); _pickCursor(byKeyboard: false); }, onHover: () => _setCursor(idx), child: Row(children: [ Expanded( child: _highlight(o.name, kw, t, sel ? t.primary : t.text, 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), ], ]), ); } void _setCursor(int i) { if (_cursor == i) return; _cursor = i; _entry?.markNeedsBuild(); } Widget _highlight( String name, String kw, AppTokens t, Color base, FontWeight w) { if (kw.isEmpty) { return Text(name, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: AppDims.fsBody, color: base, fontWeight: w)); } final lower = name.toLowerCase(); final i = lower.indexOf(kw.toLowerCase()); if (i < 0) { return Text(name, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: AppDims.fsBody, color: base, fontWeight: w)); } return RichText( overflow: TextOverflow.ellipsis, text: TextSpan( style: TextStyle(fontSize: AppDims.fsBody, color: base, fontWeight: w), children: [ TextSpan(text: name.substring(0, i)), TextSpan( text: name.substring(i, i + kw.length), style: TextStyle(color: t.primary, fontWeight: FontWeight.w700)), TextSpan(text: name.substring(i + kw.length)), ], ), ); } Widget _buildCount(AppTokens t) { final total = _sourceOptions.length; final kw = _kw.trim(); final matched = _entries.where((e) => !e.isCreate).length; return 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( kw.isEmpty ? '共 $total 条结果' : '匹配 $matched 条 · 共 $total 条', style: TextStyle(fontSize: AppDims.fsXs, color: t.muted), ), ); } // ── 输入框本体 ───────────────────────────────────────────────────────── @override Widget build(BuildContext context) { final t = context.tokens; final has = widget.selectedId != null; final height = widget.cell ? 32.0 : 38.0; final borderColor = widget.hasError ? t.danger : (_open ? t.primary : (widget.cell ? Colors.transparent : t.border)); return CompositedTransformTarget( link: _link, child: Container( height: height, decoration: BoxDecoration( color: widget.cell ? (_open ? t.surface : Colors.transparent) : t.surface, border: Border.all(color: borderColor), borderRadius: BorderRadius.circular(widget.cell ? 5 : AppDims.rMd), boxShadow: _open ? [ BoxShadow( color: t.brand50, blurRadius: 0, spreadRadius: widget.cell ? 2 : 3) ] : null, ), child: Row( children: [ Expanded( child: Focus( focusNode: widget.focusNode, onKeyEvent: _onKey, child: Builder(builder: (context) { // 单据头 combo:触发框始终只读展示(输入在浮层顶部搜索框); // 网格 combo:聚焦时就地变为可输入过滤框。 if (!_open || _docHead) { return GestureDetector( behavior: HitTestBehavior.opaque, onTap: widget.enabled ? () => widget.focusNode.requestFocus() : null, child: Padding( padding: EdgeInsets.only( left: widget.cell ? 8 : 11, right: 4), child: Align( alignment: Alignment.centerLeft, child: Text( has ? _selectedName : widget.hint, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: AppDims.fsBody, color: has ? t.text : t.faint), ), ), ), ); } return TextField( controller: _ctrl, autofocus: true, style: TextStyle(fontSize: AppDims.fsBody, color: t.text), cursorColor: t.primary, decoration: InputDecoration( isCollapsed: true, contentPadding: EdgeInsets.only(left: widget.cell ? 8 : 11, right: 4), border: InputBorder.none, filled: false, enabledBorder: InputBorder.none, focusedBorder: InputBorder.none, hintText: has ? _selectedName : widget.hint, hintStyle: TextStyle(fontSize: AppDims.fsBody, color: t.faint), ), onChanged: _onInput, ); }), ), ), // caf:有值→X 清除;无值→chevron GestureDetector( behavior: HitTestBehavior.opaque, onTap: widget.enabled ? () { if (has) { _clear(); } else { widget.focusNode.requestFocus(); } } : null, child: Padding( padding: EdgeInsets.only(right: widget.cell ? 6 : 8, left: 2), child: Icon( has ? LucideIcons.x : LucideIcons.chevronDown, size: 14, color: t.faint, ), ), ), ], ), ), ); } } class _PopRow extends StatelessWidget { final bool cur; final Widget child; final VoidCallback onTap; final VoidCallback onHover; final Color? topBorder; const _PopRow({ required this.cur, required this.child, required this.onTap, required this.onHover, this.topBorder, }); @override Widget build(BuildContext context) { final t = context.tokens; return MouseRegion( onEnter: (_) => onHover(), child: GestureDetector( onTap: onTap, behavior: HitTestBehavior.opaque, child: Container( height: 36, padding: const EdgeInsets.symmetric(horizontal: 10), alignment: Alignment.centerLeft, decoration: BoxDecoration( color: cur ? t.rowHover : Colors.transparent, borderRadius: BorderRadius.circular(AppDims.rSm), border: topBorder != null ? Border(top: BorderSide(color: topBorder!)) : null, ), child: child, ), ), ); } }